Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b822841
Initial commit with task details for issue #37
konard Sep 9, 2025
89fbf3d
Remove CLAUDE.md - PR created successfully
konard Sep 9, 2025
144dd89
Fix multi-line string escaping issues with special characters
konard Sep 9, 2025
3f53a7c
Bump version to 0.7.2 for multi-line escaping fix release
konard Sep 9, 2025
efb8fa0
Merge origin/main and resolve obsolete multiline fix
konard Sep 13, 2026
59fa13b
fix: preserve exact multiline output
konard Sep 13, 2026
40476cc
test: expand multiline parity coverage
konard Sep 13, 2026
bc77d9a
docs: explain exact multiline writes
konard Sep 13, 2026
2cb0134
Merge latest origin/main
konard Sep 13, 2026
341f9db
test: wait for SIGINT readiness
github-actions[bot] Sep 13, 2026
d9a4608
fix: reap children after capture errors
github-actions[bot] Sep 13, 2026
5415625
test: clean up failed signal fixtures
github-actions[bot] Sep 13, 2026
8cc9ae0
docs: make multiline snippets standalone
github-actions[bot] Sep 13, 2026
413d8d5
test: surface competitor import failures
github-actions[bot] Sep 13, 2026
ae7b44f
test: wait for custom stdin readiness
github-actions[bot] Sep 13, 2026
72df839
test: make exact output assertions portable
github-actions[bot] Sep 13, 2026
75cfc3e
test: make exact capture portable
github-actions[bot] Sep 13, 2026
a227e6b
fix: retain external command settings
github-actions[bot] Sep 13, 2026
d787278
test: use native Windows no-newline output
github-actions[bot] Sep 13, 2026
5fb8388
test: avoid Windows shell quote escaping
github-actions[bot] Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions experiments/issue-37-multiline-competitors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Compare multiline interpolation with /bin/sh and current shell libraries.
// Optional competitors can be installed outside the repository and supplied as:
// COMPETITOR_NODE_MODULES=/tmp/deps/node_modules bun experiments/issue-37-multiline-competitors.mjs
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { $ } from '../js/src/$.mjs';

const CONTENT = `# Test Repository

Literal \`backticks\`, "double quotes", 'single quotes', $HOME, \${name},
C:\\Program Files\\Example, and $(printf never-executed).`;

async function importOptional(name) {
const modules = process.env.COMPETITOR_NODE_MODULES;
if (modules) {
const candidate = path.join(path.resolve(modules), name);
if (fs.existsSync(candidate)) {
return await import(pathToFileURL(candidate).href);
}
}

try {
return await import(name);
} catch (error) {
if (
error?.code === 'ERR_MODULE_NOT_FOUND' ||
error?.code === 'MODULE_NOT_FOUND'
) {
return null;
}
throw error;
}
}

const expected = execFileSync('/bin/sh', ['-c', `printf '%s' "$V"`], {
encoding: 'utf8',
env: { ...process.env, V: CONTENT },
});

const runners = {
'command-stream': async () =>
(await $({ mirror: false })`printf %s ${CONTENT}`).stdout,
'Bun Shell': async () => {
if (typeof Bun === 'undefined') {
return null;
}
const { $: bunShell } = await import('bun');
return (await bunShell`printf %s ${CONTENT}`.quiet()).stdout.toString();
},
zx: async () => {
const zx = await importOptional('zx');
if (!zx?.$) {
return null;
}
return (await zx.$({ quiet: true })`printf %s ${CONTENT}`).stdout;
},
execa: async () => {
const execa = await importOptional('execa');
if (!execa?.execa) {
return null;
}
return (await execa.execa`printf %s ${CONTENT}`).stdout;
},
};

let failed = false;
for (const [name, run] of Object.entries(runners)) {
try {
const actual = await run();
if (actual === null) {
console.log(`SKIP ${name} (not installed)`);
} else if (actual === expected) {
console.log(`PASS ${name}`);
} else {
failed = true;
console.error(`FAIL ${name}: ${JSON.stringify(actual)}`);
}
} catch (error) {
failed = true;
console.error(`FAIL ${name}: ${error.message}`);
}
}

if (failed) {
process.exitCode = 1;
}
7 changes: 7 additions & 0 deletions js/.changeset/calm-files-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'command-stream': patch
---

Preserve exact multiline output, including missing final newlines, document safe
multiline file writes, and keep shell settings stable for in-flight external
commands.
19 changes: 19 additions & 0 deletions js/BEST-PRACTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ await $`printf '%s' ${json} > ${outputFile}`;
identically. For a file-only operation, `fs.writeFile(outputFile, json)` is
simpler and avoids a shell.

### Multiline Text and Exact File Writes

Multiline interpolations are one literal argument, just like a quoted shell
variable. Backticks, dollar signs, quotes, backslashes, and newlines in the
value are data and are not evaluated as shell syntax:

```javascript
const outputFile = 'generated.md';
const content = `# Generated

Literal: \`code\`, $HOME, \${name}, "quotes", and C:\\Tools`;
await $`printf '%s' ${content} > ${outputFile}`;
```

`echo` adds its normal trailing newline and its option/escape handling varies
between shells. Use `printf '%s'` when byte-for-byte text output matters. For
large text, pipe the value through `stdin`; for binary data, skip the shell and
use `fs.writeFile`. Never use `raw()` for untrusted content.

### Paths With Spaces

Interpolate the path as-is. An interpolated value always becomes exactly one
Expand Down
20 changes: 20 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,26 @@ safe, unsurprising literal contract. The
`COMMAND_STREAM_QUOTE_CONTEXT` switches remain available only for legacy
general quoting compatibility.

### Multiline Text and Exact File Writes

Interpolated multiline strings stay one literal argument. Their backticks,
dollar signs, quotes, backslashes, and newlines are data rather than shell
syntax:

```javascript
const outputFile = 'generated.md';
const content = `# Generated

Literal: \`code\`, $HOME, \${name}, "quotes", and C:\\Tools`;
await $`printf '%s' ${content} > ${outputFile}`;
```

`echo` still adds its normal trailing newline. Prefer `printf '%s'` when the
file must match the string exactly, or pass large text through the `stdin`
option. Use `fs.writeFile` for binary data. See
[`examples/multiline-content.mjs`](examples/multiline-content.mjs) for both
text-writing patterns.

### Go templates & `{{ }}` arguments

`command-stream` gives you a real shell's word-splitting, including for tokens
Expand Down
1 change: 1 addition & 0 deletions js/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ The simplest examples to get started:

### 🔤 Quoting and Paths

- `multiline-content.mjs` - Exact multiline file writes with `printf` or stdin (GitHub issue #37)
- `paths-with-spaces.mjs` - File paths with spaces need no manual quoting (GitHub issue #41)
- `quote-context-bash-c.mjs` - Interpolating inside your own quotes (GitHub issue #49)
- `json-interpolation.mjs` - Pass JSON literally and redirect it without manual escaping (GitHub issue #39)
Expand Down
27 changes: 27 additions & 0 deletions js/examples/multiline-content.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Write multiline text exactly, including shell metacharacters (issue #37).
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { $ } from '../src/$.mjs';

const content = `# Generated file

Literal values stay literal: \`backticks\`, $HOME, \${name}, "quotes",
'apostrophes', and C:\\Program Files\\Example.`;

const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'multiline example '));
const printfFile = path.join(dir, 'written with printf.md');
const stdinFile = path.join(dir, 'written through stdin.md');

try {
await $({ mirror: false })`printf '%s' ${content} > ${printfFile}`;
await $({ mirror: false, stdin: content })`cat > ${stdinFile}`;

assert.equal(fs.readFileSync(printfFile, 'utf8'), content);
assert.equal(fs.readFileSync(stdinFile, 'utf8'), content);
console.log('Both files preserve the multiline content exactly.');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
13 changes: 9 additions & 4 deletions js/src/$.process-runner-execution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,10 @@ export function attachExecutionMethods(ProcessRunner, deps) {
};

ProcessRunner.prototype._doStartAsync = async function () {
// Keep external-process options stable when another task changes or resets
// the process-wide defaults while this command is running (issue #170).
const shellSettings = { ...globalShellSettings };

// The await/then path can reach here without start()'s option merge.
setupExternalAbortSignal(this);
// Preserve the public lifecycle contract: accessing a stream starts the
Expand Down Expand Up @@ -1166,7 +1170,7 @@ export function attachExecutionMethods(ProcessRunner, deps) {
this.spec.mode === 'shell' && !shellArgv
? this.spec.command
: argv.join(' ');
logShellTrace(globalShellSettings, traceCmd);
logShellTrace(shellSettings, traceCmd);

// Detect interactive mode
const isInteractive = isInteractiveMode(stdin, this.options);
Expand Down Expand Up @@ -1204,7 +1208,7 @@ export function attachExecutionMethods(ProcessRunner, deps) {
})}`
);

throwErrexitIfNeeded(this, globalShellSettings);
throwErrexitIfNeeded(this, shellSettings);

return this.result;
} catch (error) {
Expand Down Expand Up @@ -1433,6 +1437,7 @@ export function attachExecutionMethods(ProcessRunner, deps) {

this.started = true;
this._mode = 'sync';
const shellSettings = { ...globalShellSettings };

const { cwd, env, stdin } = this.options;
const shellArgv = isShellArgvSpec(this.spec);
Expand All @@ -1442,15 +1447,15 @@ export function attachExecutionMethods(ProcessRunner, deps) {
this.spec.mode === 'shell' && !shellArgv
? this.spec.command
: argv.join(' ');
logShellTrace(globalShellSettings, traceCmd);
logShellTrace(shellSettings, traceCmd);

const result = executeSyncProcess(argv, {
cwd,
env,
stdin,
shell: shellArgv,
});
return processSyncResult(this, result, globalShellSettings);
return processSyncResult(this, result, shellSettings);
};

// Promise interface
Expand Down
10 changes: 6 additions & 4 deletions js/tests/ctrl-c-signal.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -738,11 +738,12 @@ describe.skipIf(isWindows)('CTRL+C with Different stdin Modes', () => {
`
import { $ } from './js/src/$.mjs';

const runner = \$({ stdin: 'custom input' })\`sleep 2\`;
console.log('STARTING_SLEEP_WITH_CUSTOM_STDIN');

try {
// This should bypass virtual sleep and use real /usr/bin/sleep
const result = await \$({ stdin: 'custom input' })\`sleep 2\`;
const result = await runner;
console.log('SLEEP_COMPLETED: ' + result.code);
} catch (error) {
console.log('SLEEP_ERROR: ' + error.message);
Expand All @@ -762,8 +763,9 @@ describe.skipIf(isWindows)('CTRL+C with Different stdin Modes', () => {
stdout += data.toString();
});

// Give it time to start then interrupt
await new Promise((resolve) => setTimeout(resolve, 500));
// Wait until the imported library has installed its SIGINT handler.
// A fixed delay can signal Node before startup completes under load.
await waitForOutput(() => stdout, 'STARTING_SLEEP_WITH_CUSTOM_STDIN');
child.kill('SIGINT');

const exitCode = await new Promise((resolve) => {
Expand Down
76 changes: 63 additions & 13 deletions js/tests/examples.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ import { trace } from '../src/$.utils.mjs';
import { readdirSync, statSync, readFileSync } from 'fs';
import { join } from 'path';

const waitForOutput = (child, readOutput, expected, timeoutMs = 3000) =>
new Promise((resolve, reject) => {
const finish = (callback, value) => {
clearInterval(interval);
clearTimeout(timeout);
child.off('error', onError);
child.off('exit', onExit);
callback(value);
};
const check = () => {
if (readOutput().includes(expected)) {
finish(resolve);
}
};
const onError = (error) => finish(reject, error);
const onExit = (code, signal) =>
finish(
reject,
new Error(
`Child exited before readiness output (code=${code}, signal=${signal})`
)
);
const interval = setInterval(check, 25);
const timeout = setTimeout(
() =>
finish(
reject,
new Error(`Timed out waiting for child output: ${expected}`)
),
timeoutMs
);

child.once('error', onError);
child.once('exit', onExit);
check();
});

// Get all .mjs examples
const examplesDir = join(process.cwd(), 'js/examples');
const allExamples = readdirSync(examplesDir)
Expand Down Expand Up @@ -184,23 +221,36 @@ describe('Examples Execution Tests', () => {
stderr += data.toString();
});

// Give the process time to set up its signal handler
await new Promise((resolve) => setTimeout(resolve, 500));

// Send SIGINT to the process
child.kill('SIGINT');

// Wait for the process to exit
const exitCode = await new Promise((resolve) => {
child.on('close', (code) => {
let closed = false;
const closePromise = new Promise((resolve) => {
child.once('close', (code) => {
closed = true;
resolve(code);
});
});

// The user's SIGINT handler should have been called with exit code 42
expect(exitCode).toBe(42);
expect(stdout).toContain('USER_SIGINT_HANDLER_CALLED');
expect(stdout).not.toContain('TIMEOUT_REACHED');
try {
// Signal only after the child confirms its handler is installed. A
// fixed delay is flaky when the full suite puts the host under load.
await waitForOutput(
child,
() => stdout,
'Process started, waiting for SIGINT...'
);

child.kill('SIGINT');
const exitCode = await closePromise;

// The user's SIGINT handler should have been called with exit code 42
expect(exitCode).toBe(42);
expect(stdout).toContain('USER_SIGINT_HANDLER_CALLED');
expect(stdout).not.toContain('TIMEOUT_REACHED');
} finally {
if (!closed) {
child.kill('SIGKILL');
await closePromise;
}
}
},
{ timeout: 5000 }
);
Expand Down
21 changes: 21 additions & 0 deletions js/tests/issue-170-cleanup-race.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ import './test-helper.mjs'; // installs beforeEach/afterEach resetGlobalState
import { $, shell, resetGlobalState } from '../src/$.mjs';

describe('issue #170 - CI false positives', () => {
test('an in-flight external command retains its errexit setting', async () => {
shell.errexit(true);
const runner = $({
mirror: false,
})`sh -c "sleep 0.1; exit 5"`;
const settled = runner.then(
(result) => ({ status: 'fulfilled', result }),
(error) => ({ status: 'rejected', error })
);
const timer = setTimeout(() => shell.errexit(false), 20);

try {
const outcome = await settled;
expect(outcome.status).toBe('rejected');
expect(outcome.error?.code).toBe(5);
} finally {
clearTimeout(timer);
shell.errexit(false);
}
});

test('resetGlobalState() during an awaited command preserves the real exit code', async () => {
// Fire a global reset while the command below is still running. This mirrors
// the Windows/Bun timing where a test-isolation reset raced an in-flight,
Expand Down
Loading
Loading