Appearance
Shell Command Tag
When to use
Use the sh tag when executing shell commands from Node.js and you need safe interpolation of dynamic values. The sh tagged template literal builds shell command strings with automatic escaping, preventing shell injection while keeping the syntax readable. Use instead of manual string concatenation or template literals for shell commands.
The pattern
A tagged template function sh that escapes interpolated values for shell safety and returns a ShellCommand object with run() and runSync() methods:
javascript
import { sh } from "sh-cmd-tag";
// Async execution
const { output } = await sh`echo ${userInput}`;
// Sync execution
const { output } = sh`git branch --show-current`.runSync();Using sh for build and deploy commands
The sh tag builds a shell command from a template literal, escaping all interpolated values for safety. Call run() for async execution or runSync() for synchronous use:
javascript
import { sh } from "sh-cmd-tag";
sh`npm run build --workspace=ui`.run();Deploy commands can use the returned child process to monitor output and check exit codes:
javascript
const deployProcess = sh`
gcloud app deploy app.yaml
--no-promote
--project=my-app
--version=${name}
--quiet
`.run({ cwd: PROJECT_ROOT });
deployProcess.stdout.pipe(process.stdout);
deployProcess.stderr.pipe(process.stderr);
const { code } = await deployProcess;
if (code === 0) {
await tagDeployment(name);
}Use runSync() for quick commands where you need the result immediately:
javascript
function gitBranchName() {
const { stdout } = sh`
git branch --show-current
`.runSync();
return String(stdout).trim().replaceAll("/", "-");
}Long-lived processes work well with sh and the child process cleanup pattern:
javascript
async function startDev() {
const dbProxy = sh`npx viaticus db proxy`.run();
dbProxy.on("exit", (code, signal) => {
console.info(
`DB proxy exited: code=${code} signal=${signal}`
);
process.exit(code);
});
sh`nodemon --inspect server/index.js`.run({
cwd: String(appRoot),
});
}Implementing the ShellCommand class
The tag returns a ShellCommand object, not a raw string. This separates command construction from execution:
javascript
class ShellCommand {
constructor(shellString) {
this.command = shellString;
}
valueOf() {
return this.command;
}
toString() {
return this.command;
}
run(options = {}) {
const defaults = { shell: true, sync: false };
const resolved = { ...defaults, ...options };
return run(this.command, [], resolved);
}
runSync(options = {}) {
const defaults = { shell: true, sync: true };
const resolved = { ...defaults, ...options };
return run(this.command, [], resolved);
}
}Shell-safe interpolation
Interpolated values are automatically escaped for the shell context. Falsy values (null, undefined, false) are filtered out, enabling conditional arguments:
javascript
const command = sh`
node --test
${options.watch ? "--watch" : undefined}
${testFiles}
`;
await command.run({ stdio: "inherit" });Objects become flags, arrays become space-separated args:
javascript
// { verbose: true, port: 3000 }
// → --verbose --port=3000
// ["file1.js", "file2.js"]
// → file1.js file2.jsMulti-line commands
Multi-line tagged templates are collapsed into a single line, so you can format commands for readability:
javascript
const result = await sh`
gcloud app deploy app.yaml
--no-promote
--project=my-project
--version=${versionName}
--quiet
`.run({ cwd: PROJECT_ROOT });Trade-offs
- Requires a library. The
sh-cmd-tagpackage (or an equivalent likepuka) handles escaping. Rolling your own escaping is error-prone and not recommended. - Shell mode is always on. The
run()method defaults to shell mode. This is intentional — the escaping assumes a shell context — but means you pay the overhead of a shell process for every command. - Falsy filtering can surprise.
0and""are falsy in JavaScript but may be valid arguments. Use explicit checks (value !== undefined) when0or empty string are meaningful. - Multi-line collapsing. The
oneLinetag collapses whitespace, which is usually what you want. But if your command needs literal newlines (rare in shell commands), this pattern won't work.