Command Execution with Template Literals
The heart of Xec is its template literal syntax for command execution. This guide covers executing commands using the $ template literal.
Basic Syntax
The $ function uses JavaScript template literals to execute shell commands:
import { $ } from '@xec-sh/core';
// Simple command
await $`echo "Hello, World!"`;
// With variables
const name = 'Alice';
await $`echo "Hello, ${name}!"`;
// Multi-line commands
await $`
echo "Line 1"
echo "Line 2"
echo "Line 3"
`;
ProcessPromise API
Every command returns a ProcessPromise with rich functionality:
const promise = $`ls -la`;
// ProcessPromise methods (chainable)
promise
.quiet() // Suppress stdout/stderr
.nothrow() // Don't throw on non-zero exit
.timeout(5000) // Set timeout in milliseconds
.cwd('/tmp') // Set working directory
.env({KEY: 'value'}) // Set environment variables
.pipe(stream) // Pipe output to stream
// stdin is a property, not a method — write to it directly
promise.stdin.end('input');
// Await for result
const result = await promise;
Working with Output
Capturing Output
// Get stdout as a trimmed string
const files = await $`ls`.text();
console.log(files);
// Access all properties
const result = await $`echo "test"`;
console.log(result.stdout); // 'test\n'
console.log(result.stderr); // ''
console.log(result.exitCode); // 0
console.log(result.signal); // undefined
console.log(result.duration); // execution time in ms
Streaming Output
// Stream to stdout in real-time
await $`npm install`.pipe(process.stdout);
// Stream to file
import { createWriteStream } from 'fs';
const logFile = createWriteStream('output.log');
await $`npm test`.pipe(logFile);
// Stream stderr separately
await $`npm build`
.pipe(process.stdout)
.stderr(process.stderr);
Quiet and Verbose Modes
// Quiet mode - suppress all output
await $`npm install`.quiet();
// Verbose mode - show each command before it runs. Unlike `.quiet()`, this
// is a setting on the engine, not something chained onto one command.
$.verbose = true;
await $`rm -rf node_modules`;
// Output: $ rm -rf node_modules
// Combine with quiet to see the command but not its output
await $`npm test`.quiet();
Error Handling
Default Behavior
By default, commands throw on non-zero exit codes:
try {
await $`exit 1`;
} catch (error) {
console.error('Command failed:', error.message);
console.error('Exit code:', error.exitCode);
console.error('Stderr:', error.stderr);
}
Non-Throwing Mode
Use .nothrow() to handle errors manually:
const result = await $`exit 1`.nothrow();
if (result.exitCode !== 0) {
console.log('Command failed with exit code:', result.exitCode);
} else {
console.log('Command succeeded');
}
// Or use the ok property (true only when exitCode is 0 and no signal fired)
if (!result.ok) {
console.error('Failed:', result.cause);
}
Retry Logic
async function retryCommand(cmd, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const result = await cmd.nothrow();
if (result.exitCode === 0) {
return result;
}
console.log(`Attempt ${i + 1} failed, retrying...`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error(`Command failed after ${maxRetries} attempts`);
}
// Usage
const result = await retryCommand($`curl https://api.example.com`);
Environment and Working Directory
Setting Environment Variables
// Set single variable
await $`echo $MY_VAR`.env({ MY_VAR: 'test' });
// Merge with existing environment
await $`node script.js`.env({
...process.env,
NODE_ENV: 'production',
API_KEY: 'secret'
});
// Using environment in template
const apiKey = 'secret123';
await $`curl -H "Authorization: ${apiKey}" https://api.example.com`;
Working Directory
// Change working directory
await $`npm install`.cwd('/path/to/project');
// Chain multiple operations
await $`pwd`.cwd('/tmp'); // Outputs: /tmp
// Use with relative paths
const projectDir = './my-project';
await $`npm test`.cwd(projectDir);
Input/Output Redirection
Providing Input
stdin is a property — a writable stream — not a method. It can be written
to before the command has started: writes are buffered and forwarded once
the process spawns.
// Provide stdin as a string
const cat = $`cat`;
cat.stdin.end('Hello from stdin\n');
await cat;
// Pipe from a file
import { createReadStream } from 'fs';
const sort = $`sort`;
createReadStream('input.txt').pipe(sort.stdin);
await sort;
// Interactive input — inherit the parent's stdio wholesale
await $`npm init`.interactive();
Output Redirection
// Redirect to file (shell style)
await $`echo "test" > output.txt`;
// Programmatic redirection
import { createWriteStream } from 'fs';
const outFile = createWriteStream('output.txt');
await $`ls -la`.pipe(outFile);
// Append to file
await $`echo "append" >> output.txt`;
Pipes and Chains
Shell Pipes
// Using shell pipe operator
await $`cat file.txt | grep "pattern" | wc -l`;
// Multiple pipes
await $`ps aux | grep node | awk '{print $2}'`;
Programmatic Pipes
// Pipe between commands
await $`ls`.pipe($`grep ".js"`);
// Process output before piping
const processed = await $`cat data.json`.json();
await $`echo ${JSON.stringify(processed)} | jq '.'`;
Timeout Handling
// Set timeout in milliseconds
try {
await $`sleep 10`.timeout(5000);
} catch (error) {
console.error('Command timed out after 5 seconds');
}
// With custom timeout error handling
const result = await $`long-running-command`
.timeout(30000)
.nothrow();
if (!result.ok) {
console.log(`Command did not finish: ${result.cause}`);
}
A signalled process is never ok, and its exitCode is 128+n for signal
number n. result.signal is set when the signal lands on the direct child
process (for example after .kill('SIGTERM')); when a wrapper shell absorbs
the signal instead, signal can be absent and only the exit code and cause
tell you what happened — so branch on .ok/.cause, not on signal.
Shell Features
Variable Expansion
// Shell variable expansion — $HOME passes through to the shell untouched.
// ${...} inside the template is JavaScript interpolation, not shell syntax,
// so `echo ${HOME}` would be a ReferenceError unless HOME is a JS variable.
await $`echo $HOME`;
await $`echo $HOME/Documents`;
// JavaScript variable in command
const dir = '/tmp';
await $`cd ${dir} && ls`;
Globbing
// Shell globbing
await $`rm *.tmp`;
await $`ls **/*.js`;
// Escape globbing when needed
const filename = 'file[1].txt';
await $`cat ${filename}`; // May not work as expected
await $`cat "${filename}"`; // Properly escaped
Command Substitution
// Using command substitution
await $`echo "Current date: $(date)"`;
// JavaScript alternative
const date = await $`date`.text();
await $`echo "Current date: ${date}"`;
Advanced Patterns
Conditional Execution
// Shell conditional operators
await $`test -f file.txt && echo "File exists"`;
await $`test -d dir || mkdir dir`;
// JavaScript conditionals
const fileExists = await $`test -f file.txt`.nothrow();
if (fileExists.exitCode === 0) {
await $`cat file.txt`;
} else {
await $`touch file.txt`;
}
Background Processes
// Start background process (returns immediately)
const server = $`npm run server`;
// Do other work
await $`npm test`;
// Wait for background process
await server;
// Or kill it
server.kill('SIGTERM');
Process Groups
// Execute multiple commands in sequence, echoing each one first
$.verbose = true;
async function buildProject() {
const steps = [
$`npm install`,
$`npm run lint`,
$`npm run test`,
$`npm run build`
];
for (const step of steps) {
await step;
}
}
// Execute in parallel
async function parallelTasks() {
const results = await Promise.all([
$`npm run test:unit`,
$`npm run test:integration`,
$`npm run test:e2e`
]);
results.forEach((result, i) => {
console.log(`Task ${i}: Exit code ${result.exitCode}`);
});
}
Platform Differences
Cross-Platform Commands
import { platform } from 'os';
// Platform-specific commands
if (platform() === 'win32') {
await $`dir`;
} else {
await $`ls`;
}
// Use cross-platform alternatives
await $`node -e "console.log(process.cwd())"`; // Instead of pwd
await $`node -e "console.log(os.homedir())"`; // Instead of echo $HOME
Shell Selection
// Xec defaults to /bin/sh on Unix and cmd.exe on Windows — the same default
// Node's child_process.spawn({ shell: true }) uses. Setting process.env.SHELL
// has no effect on which shell actually runs the command.
// Force a specific shell (advanced)
await $`echo $BASH_VERSION`.shell('/bin/bash');
Performance Optimization
Command Batching
// Inefficient - multiple shell invocations
await $`mkdir -p dir1`;
await $`mkdir -p dir2`;
await $`mkdir -p dir3`;
// Efficient - single shell invocation
await $`mkdir -p dir1 dir2 dir3`;
// Or use shell features
await $`
mkdir -p dir1
mkdir -p dir2
mkdir -p dir3
`;
Output Buffering
// For large outputs, stream instead of buffering
// Bad for large files
const hugeFile = await $`cat very-large-file.txt`;
// Good - stream processing
await $`cat very-large-file.txt`.pipe(process.stdout);
// Or process line by line as the command runs
for await (const line of $`cat very-large-file.txt`) {
// Process each line
}
Security Considerations
Command Injection Prevention
Template interpolation escapes every value it's given, so it's the safe default — no extra escaping library is needed:
// SAFE - interpolated values are quoted as a single argument, not parsed
// by the shell. This prints the literal string "; rm -rf /"; it does not
// run it.
const userInput = '; rm -rf /';
await $`echo ${userInput}`;
An array passed to $`...` or $.run(...) is a way to supply a whole
command as pre-built segments, not an escaping mechanism — its elements are
spliced into the command line unescaped. Interpolating untrusted input into
one is exactly as dangerous as building the string by hand:
// DANGEROUS - array elements are not escaped; this actually runs `echo`
// AND the injected `rm -rf /`
await $(['echo', userInput]); // DON'T DO THIS
For argv assembled entirely from untrusted parts, bypass the shell instead of relying on quoting at all:
// SAFE - no shell is involved, so there is nothing to inject into
await $.execute({ command: 'echo', args: [userInput], shell: false });
Sensitive Data
// Don't log sensitive commands
const password = 'secret123';
await $`mysql -u root -p${password}`.quiet(); // Hide output
// Use environment variables for secrets
await $`mysql -u root`.env({ MYSQL_PWD: password });
Complete Example
Here's a comprehensive example showcasing various command execution patterns:
// deploy.js - Complete deployment script
import { $ } from '@xec-sh/core';
import chalk from 'chalk';
async function deploy() {
console.log(chalk.blue('Starting deployment...'));
// Check prerequisites
const gitStatus = await $`git status --porcelain`.nothrow().text();
if (gitStatus) {
throw new Error('Working directory not clean');
}
// Build the project
console.log(chalk.yellow('Building project...'));
await $`npm run build`
.timeout(60000)
.pipe(process.stdout);
// Run tests
console.log(chalk.yellow('Running tests...'));
const testResult = await $`npm test`.nothrow();
if (testResult.exitCode !== 0) {
console.error(chalk.red('Tests failed!'));
console.error(testResult.stderr);
process.exit(1);
}
// Create deployment directory
const deployDir = '/var/www/app';
await $`sudo mkdir -p ${deployDir}`.quiet();
// Copy files
console.log(chalk.yellow('Copying files...'));
$.verbose = true;
await $`sudo rsync -av --delete dist/ ${deployDir}/`;
// Restart service
console.log(chalk.yellow('Restarting service...'));
await $`sudo systemctl restart app.service`;
// Verify deployment
const healthCheck = await $`curl -f http://localhost:3000/health`
.timeout(5000)
.nothrow();
if (healthCheck.exitCode === 0) {
console.log(chalk.green('✅ Deployment successful!'));
} else {
console.error(chalk.red('❌ Health check failed'));
await $`sudo systemctl status app.service`;
process.exit(1);
}
}
// Execute with error handling
deploy().catch(error => {
console.error(chalk.red('Deployment failed:'), error.message);
process.exit(1);
});
This example demonstrates:
- Error checking with
.nothrow() - Timeout handling
- Output streaming
- Conditional execution
- Service management
- Health checks
- Proper error handling and exit codes