Skip to main content

Template Literals API

Template literals are the primary way to build commands in Xec. This API provides safe variable injection into commands with automatic escaping, preventing injections and errors.

Basic Usage

Simple Command Execution

import { $ } from '@xec-sh/core';

// Simple command
await $`ls -la`;

// With variables
const dir = '/home/user';
await $`ls -la ${dir}`;

// Multi-line commands
await $`
echo "Starting process..."
npm install
npm build
echo "Process completed"
`;

Automatic Escaping

All values substituted through ${} are automatically escaped:

// Files with spaces
const file = "my document.txt";
await $`cat ${file}`;
// Executes: cat 'my document.txt'

// Special characters — a literal single quote inside the value is closed,
// escaped, and reopened (the standard POSIX trick), not double-quoted
const dangerous = "'; rm -rf /; echo '";
await $`echo ${dangerous}`;
// Executes: echo ''\''; rm -rf /; echo '\'''
// Output: '; rm -rf /; echo '

The quoting shown here is POSIX, because that is the shell in play. Values are always quoted for the shell that will actually parse the command — cmd.exe gets caret escaping, PowerShell gets its own — so the protection and the parser never disagree. Set the shell with .shell(...) and the escaping follows.

The value arrives intact either way; what a shell then prints is its own convention, and echo differs between them. See Windows and cross-platform scripts.


// Command injection attempt
const userInput = "$(malicious command)";
await $`echo ${userInput}`;
// Safe! Outputs: $(malicious command)

Values are quoted with single quotes (POSIX shells don't expand anything inside single quotes), not double quotes — this matters if you're predicting the exact command string, e.g. for logs or tests.

Data Types and Their Handling

Strings

Strings are escaped with context awareness:

const text = "Hello, World!";
await $`echo ${text}`; // echo 'Hello, World!'

const path = "/path/with spaces/file.txt";
await $`cat ${path}`; // cat '/path/with spaces/file.txt'

const quote = 'He said "Hello"';
await $`echo ${quote}`; // echo 'He said "Hello"' — double quotes need no escaping inside single quotes

Numbers and Boolean Values

const port = 3000;
const count = 42;
const enabled = true;

await $`node server.js --port ${port}`; // --port 3000
await $`head -n ${count} file.txt`; // head -n 42
await $`./script.sh --verbose ${enabled}`; // --verbose true

Arrays

Arrays are expanded into separate arguments:

const files = ['file1.txt', 'file2.txt', 'file3.txt'];
await $`rm ${files}`;
// Executes: rm file1.txt file2.txt file3.txt

const flags = ['-v', '--recursive', '--force'];
await $`command ${flags} target`;
// Executes: command -v --recursive --force target

// Empty array is ignored
const empty: string[] = [];
await $`ls ${empty} -la`; // ls -la

Objects

Objects are converted to JSON:

const config = {
name: 'app',
version: '1.0.0',
port: 3000
};

await $`echo ${config}`;
// Executes: echo '{"name":"app","version":"1.0.0","port":3000}'

// Use in configuration files
await $`echo ${config} > config.json`;

null and undefined

null and undefined interpolate to an empty quoted argument ('') — they don't vanish, so they still occupy a position in argv:

const nullValue = null;
const undefinedValue = undefined;

await $`echo Value: ${nullValue}`; // echo Value: ''
await $`echo Value: ${undefinedValue}`; // echo Value: ''

This means undefined is not a clean way to make a flag disappear — $`command ${optionalFlag} file.txt` still passes an empty-string argument when optionalFlag is undefined, which most CLIs don't treat the same as omitting it entirely. Filter it out of an array instead:

const optionalFlag = condition ? '--verbose' : undefined;
await $`command ${[optionalFlag].filter(Boolean)} file.txt`;
// If condition false: command file.txt
// If condition true: command --verbose file.txt

Promises and Async Values

Template literals automatically await promise resolution:

// Function returns Promise
async function getVersion() {
return '1.2.3';
}

// Promise is automatically resolved
await $`npm publish --tag ${getVersion()}`;
// Executes: npm publish --tag 1.2.3

// Promise chaining
const data = fetch('/api/config').then(r => r.json());
await $`deploy --config ${data}`;

// Parallel resolution
const [user, host] = [
Promise.resolve('admin'),
Promise.resolve('server.com')
];
await $`ssh ${user}@${host}`;

Raw Mode - Without Escaping

For cases when you need to disable escaping, $.raw is a separate tagged template that skips it:

import { $ } from '@xec-sh/core';

// Normal mode - with escaping
const pattern = '*.txt';
await $`ls ${pattern}`; // ls '*.txt' (looks for file named *.txt)

// Raw mode - without escaping
await $.raw`ls ${pattern}`; // ls *.txt (works as glob)

// Useful for:
// - Glob patterns
const files = '*.{js,ts}';
await $.raw`rm ${files}`;

// - Redirections
const output = '> output.txt';
await $.raw`echo "test" ${output}`;

// - Pipes
const pipe = '| grep error';
await $.raw`cat log.txt ${pipe}`;

⚠️ Warning: Use raw mode only with trusted data!

Complex Examples

Dynamic Command Building

// Conditional flags
const verbose = process.env.DEBUG === 'true';
const dryRun = process.env.DRY_RUN === 'true';

const flags = [
verbose && '--verbose',
dryRun && '--dry-run',
'--color'
].filter(Boolean);

await $`npm publish ${flags}`;

Command Templating

// Creating a reusable template
function gitCommit(message: string, files: string[] = []) {
return $`git add ${files.length ? files : '.'} && git commit -m ${message}`;
}

await gitCommit('Initial commit');
await gitCommit('Add features', ['src/feature.ts', 'tests/feature.test.ts']);

Working with Paths

import * as path from 'path';

const baseDir = '/projects';
const projectName = 'my-app';
const fileName = 'config.json';

// Safe path construction
const fullPath = path.join(baseDir, projectName, fileName);
await $`cat ${fullPath}`;

// Multiple paths
const dirs = ['src', 'tests', 'docs'].map(d => path.join(baseDir, d));
await $`ls -la ${dirs}`;

Working with Environment

// Environment variables in commands
const env = {
NODE_ENV: 'production',
PORT: '3000',
API_KEY: 'secret-key'
};

// Pass through env
await $`node app.js`.env(env);

// Or inline
const port = 3000;
const host = 'localhost';
await $`NODE_ENV=production npm start -- --port ${port} --host ${host}`;

Special Characters and Their Handling

Quotes

// Single quotes — the value gets closed, escaped, and reopened
const single = "It's a test";
await $`echo ${single}`; // echo 'It'\''s a test'

// Double quotes — no escaping needed inside a single-quoted token
const double = 'Say "Hello"';
await $`echo ${double}`; // echo 'Say "Hello"'

// Mixed
const mixed = `It's "complex"`;
await $`echo ${mixed}`; // echo 'It'\''s "complex"'

Shell Characters

// Special characters are escaped
const special = '$HOME && ls || rm -rf /';
await $`echo ${special}`;
// Output: $HOME && ls || rm -rf /

// Backticks
const backticks = '`command`';
await $`echo ${backticks}`; // echo '`command`'

// Shell variables
const shellVar = '${PATH}';
await $`echo ${shellVar}`; // echo '${PATH}'

Unicode and Emoji

// Unicode is supported
const unicode = 'Hello, world! 你好世界';
await $`echo ${unicode}`;

// Emoji work
const emoji = '🚀 Deploying...';
await $`echo ${emoji}`;

// Special characters
const special = '→ ← ↑ ↓ • × ÷';
await $`echo ${special}`;

Function Interpolation

Call the function yourself — ${getTimestamp()}, not ${getTimestamp} — interpolation doesn't invoke a bare function reference for you; it would be stringified as its source text instead. A Promise returned by the call is awaited automatically, as shown earlier.

function getTimestamp() {
return new Date().toISOString();
}

await $`echo "Deployed at: ${getTimestamp()}"`;

// Async functions
async function getGitHash() {
return $`git rev-parse HEAD`.text();
}

await $`docker build -t app:${getGitHash()} .`;

// Object methods
const config = {
getConnectionString() {
return 'postgresql://localhost/db';
}
};

await $`psql ${config.getConnectionString()}`;

Nested Template Literals

// Commands can be nested
const branch = await $`git branch --show-current`.text();
await $`git push origin ${branch}`;

// Or in one line
await $`git push origin ${await $`git branch --show-current`.text()}`;

// Complex compositions
const files = await $`find . -name "*.js"`.lines();
await $`eslint ${files}`;

Multi-line Commands

// Shell scripts
await $`
set -e
echo "Starting deployment..."

# Update code
git pull origin main

# Install dependencies
npm ci

# Build
npm run build

# Restart
pm2 restart app

echo "Deployment completed!"
`;

// With variables
const appName = 'my-app';
const environment = 'production';

await $`
echo "Deploying ${appName} to ${environment}"
cd /apps/${appName}
git checkout ${environment}
npm run deploy:${environment}
`;

Error Handling in Template Literals

// Incorrect usage
try {
const result = $`command`; // Forgot await!
// result is ProcessPromise, not result
} catch (e) {
// This block won't execute
}

// Correct usage
try {
const result = await $`command`;
console.log(result.stdout);
} catch (error) {
console.error('Command failed:', error.stderr);
}

// With nothrow
const result = await $`may-fail`.nothrow();
if (result.exitCode !== 0) {
console.log('Failed but continued');
}

Performance and Optimizations

String Reuse

// Inefficient - creates new string each time
for (const file of files) {
await $`process ${file}`;
}

// More efficient - batch processing
await $`process ${files}`;

// Or in parallel
await $.parallel.map(files, file => $`process ${file}`);

Result Caching

// Caching expensive operations
const getData = () => $`expensive-operation`.cache({ ttl: 60000 });

// First call executes the command
const data1 = await getData();

// Second call returns cache
const data2 = await getData();

Debugging Template Literals

There is no built-in way to preview the resolved command string before running it — ProcessPromise doesn't expose the command, and calling .toString() on one just gives "[object Object]" (the default inherited from Object.prototype; it's unrelated to ExecutionResult's own .toString(), which returns stdout once a command has actually run). To see what actually ran, either turn on verbose mode, which echoes each command before it runs:

const file = "test file.txt";

$.verbose = true;
await $`cat ${file}`;
// Outputs: $ cat 'test file.txt'

or listen for the command:start event, which fires with the same string:

$.on('command:start', ({ command }) => {
console.log('Executing:', command);
});

await $`cat ${file}`;
// Outputs: Executing: cat 'test file.txt'

Best Practices

✅ Good Practices

// Use variables for readability
const sourceDir = '/source';
const destDir = '/dest';
await $`rsync -av ${sourceDir}/ ${destDir}/`;

// Break down complex commands
const files = await $`find . -type f -name "*.ts"`.lines();
const filtered = files.filter(f => !f.includes('node_modules'));
await $`prettier --write ${filtered}`;

// Use destructuring
const { stdout: version } = await $`node --version`;

❌ Avoid

// Don't use string concatenation — the whole concatenated string becomes
// one quoted argument, so this doesn't inject, but it also doesn't do what
// you meant: it tries to run a command literally named "ls <userInput>"
// and fails with "command not found"
const bad = 'ls ' + userInput;
await $`${bad}`;

// Don't forget await
const result = $`command`; // This is Promise, not result!

// Don't use raw without necessity
await $.raw`rm ${userInput}`; // Dangerous!

// Don't pass unchecked data
await $`mysql -p${userPassword}`; // Password in logs!

Conclusion

The Template Literals API in Xec provides:

  • Security: automatic escaping prevents injections
  • Convenience: natural JavaScript syntax
  • Flexibility: support for all JavaScript data types
  • Asynchronicity: automatic promise handling
  • Readability: code looks like regular shell commands

This API is the foundation for safe and convenient command execution across all environments supported by Xec.