Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 96 additions & 0 deletions packages/core/src/exec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ensureCli, exec } from './exec.js';

const tempDirs: string[] = [];
const oldPath = process.env.PATH;
const itOnWindows = process.platform === 'win32' ? it : it.skip;
const itOnNonWindows = process.platform === 'win32' ? it.skip : it;

afterEach(async () => {
process.env.PATH = oldPath;
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

describe('exec', () => {
it('preserves percent-wrapped arguments on Windows shell execution', async () => {
const binDir = await mkdtemp(join(tmpdir(), 'sh1pt-exec-bin-'));
tempDirs.push(binDir);
await installEchoArgsCli(binDir, 'sh1pt-echo-args');
process.env.PATH = `${binDir}${delimiter}${oldPath ?? ''}`;

const result = await exec('sh1pt-echo-args', [
'%SH1PT_EXEC_LITERAL%',
'C:\\tmp\\path\\',
'Foo & Bar',
'hello!world',
'quoted "value"',
], {
env: { SH1PT_EXEC_LITERAL: 'expanded-value' },
log: () => {},
});

expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout.trim())).toEqual([
'%SH1PT_EXEC_LITERAL%',
'C:\\tmp\\path\\',
'Foo & Bar',
'hello!world',
'quoted "value"',
]);
});
});

describe('ensureCli', () => {
itOnWindows('throws when Windows reports a command-not-found exit', async () => {
const binDir = await mkdtemp(join(tmpdir(), 'sh1pt-exec-bin-'));
tempDirs.push(binDir);
await installFailingCli(binDir, 'sh1pt-missing-version');
process.env.PATH = `${binDir}${delimiter}${oldPath ?? ''}`;

await expect(ensureCli('sh1pt-missing-version', 'install it', () => {}))
.rejects.toThrow('sh1pt-missing-version not installed. install it');
});

itOnNonWindows('keeps non-Windows non-zero version exits distinct from missing commands', async () => {
const binDir = await mkdtemp(join(tmpdir(), 'sh1pt-exec-bin-'));
tempDirs.push(binDir);
await installFailingCli(binDir, 'sh1pt-installed-failing-version');
process.env.PATH = `${binDir}${delimiter}${oldPath ?? ''}`;

await expect(ensureCli('sh1pt-installed-failing-version', 'install it', () => {}))
.resolves.toBeUndefined();
});
});

async function installFailingCli(binDir: string, name: string): Promise<void> {
if (process.platform === 'win32') {
await writeFile(join(binDir, `${name}.cmd`), '@echo off\r\nexit /b 9009\r\n', 'utf-8');
return;
}

const script = join(binDir, name);
await writeFile(script, '#!/usr/bin/env sh\nexit 127\n', { encoding: 'utf-8', mode: 0o755 });
}

async function installEchoArgsCli(binDir: string, name: string): Promise<void> {
const helper = join(binDir, 'echo-args.js');
await writeFile(helper, 'console.log(JSON.stringify(process.argv.slice(2)));\n', 'utf-8');

if (process.platform === 'win32') {
await writeFile(
join(binDir, `${name}.cmd`),
`@echo off\r\n"${process.execPath}" "%~dp0echo-args.js" %*\r\n`,
'utf-8',
);
return;
}

const script = join(binDir, name);
await writeFile(script, `#!/usr/bin/env sh\n"${process.execPath}" "${helper}" "$@"\n`, {
encoding: 'utf-8',
mode: 0o755,
});
}
59 changes: 54 additions & 5 deletions packages/core/src/exec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import type { SpawnOptions } from 'node:child_process';
import type { BuildContext } from './target.js';

type LogFn = BuildContext['log'];
Expand Down Expand Up @@ -28,22 +29,33 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom
}

return new Promise<ExecResult>((resolve, reject) => {
const child = spawn(cmd, args, {
const spawnOptions: SpawnOptions = {
cwd: opts.cwd,
env: { ...process.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
});
};
const useWindowsCmd = shouldUseWindowsCmd(cmd);
const windowsCommand = useWindowsCmd
? windowsCommandLine(cmd, args, spawnOptions.env)
: { command: '', env: spawnOptions.env };
const child = useWindowsCmd
? spawn('cmd.exe', ['/d', '/s', '/v:on', '/c', windowsCommand.command], {
...spawnOptions,
env: windowsCommand.env,
windowsVerbatimArguments: true,
})
: spawn(cmd, args, spawnOptions);

let stdout = '';
let stderr = '';

child.stdout.on('data', (chunk: Buffer) => {
child.stdout?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
for (const line of text.split('\n')) if (line) opts.log(line);
});

child.stderr.on('data', (chunk: Buffer) => {
child.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
for (const line of text.split('\n')) if (line) opts.log(line, 'warn');
Expand All @@ -69,9 +81,38 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom
});
}

function shouldUseWindowsCmd(cmd: string): boolean {
return process.platform === 'win32'
&& !cmd.includes('/')
&& !cmd.includes('\\')
&& !/\.(?:exe|com)$/i.test(cmd);
}

function windowsCommandLine(
cmd: string,
args: string[],
env: NodeJS.ProcessEnv | undefined,
): { command: string; env: NodeJS.ProcessEnv } {
const nextEnv: NodeJS.ProcessEnv = { ...env };
const argRefs = args.map((arg, index) => {
const name = `SH1PT_EXEC_ARG_${index}`;
nextEnv[name] = windowsEnvArg(arg);
return `"!${name}!"`;
});
return { command: [cmd, ...argRefs].join(' '), env: nextEnv };
}

function windowsEnvArg(value: string): string {
return value
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/\\+$/, '$&$&')
.replace(/!/g, '^!');
}
Comment on lines +105 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 windowsEnvArg incorrectly escapes ! as ^!. cmd.exe's delayed expansion (/v:on) substitutes !NAME! into the output buffer and continues scanning from after the closing ! in the original input — the substituted value is never re-scanned for further ! patterns. Because ^ is also not an escape character inside CommandLineToArgvW (CRT) double-quoted strings, a value like hello!world stored as hello^!world will arrive in the child process as the literal string hello^!world. The test case 'hello!world' asserts the round-trip is clean, which is the right contract, but the assertion will fail on Windows. Because the test is not guarded by itOnWindows, the breakage won't surface on Linux CI.


export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise<void> {
try {
await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false });
const result = await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false });
if (isWindowsCommandNotFound(result)) throw new Error(`command not found: ${cmd}`);
} catch (err) {
if (err instanceof Error && err.message.startsWith('command not found')) {
log(`${cmd} not found on PATH`, 'error');
Expand All @@ -80,3 +121,11 @@ export async function ensureCli(cmd: string, installHint: string, log: LogFn): P
throw err;
}
}

function isWindowsCommandNotFound(result: ExecResult): boolean {
if (process.platform !== 'win32' || result.exitCode === 0) return false;

const output = `${result.stderr}\n${result.stdout}`;
return result.exitCode === 9009
|| output.includes('is not recognized as an internal or external command');
}
20 changes: 18 additions & 2 deletions packages/docs/pandoc/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { contractTestDocs } from '@profullstack/sh1pt-core/testing';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import docs from './index.js';

Expand Down Expand Up @@ -48,7 +48,7 @@ describe('docs-pandoc generation', () => {
const binDir = await mkdtemp(join(tmpdir(), 'sh1pt-pandoc-bin-'));
tempDirs.push(outDir, binDir);
await installFakePandoc(binDir);
process.env.PATH = `${binDir}:${oldPath ?? ''}`;
process.env.PATH = `${binDir}${delimiter}${oldPath ?? ''}`;

const result = await docs.generate({ secret: () => undefined, log: () => {}, dryRun: false }, {
kind: 'whitepaper',
Expand Down Expand Up @@ -90,6 +90,22 @@ describe('docs-pandoc generation', () => {
});

async function installFakePandoc(binDir: string): Promise<void> {
if (process.platform === 'win32') {
const helper = join(binDir, 'pandoc.js');
await writeFile(helper, [
'const { writeFileSync } = require("node:fs");',
'const { dirname, join } = require("node:path");',
'const args = process.argv.slice(2);',
'const outIndex = args.indexOf("-o");',
'const out = outIndex >= 0 ? args[outIndex + 1] : "";',
'if (!out) throw new Error("missing -o");',
'writeFileSync(join(dirname(out), "pandoc-args.json"), JSON.stringify(args));',
'writeFileSync(out, "fake pandoc output\\n");',
].join('\n'), 'utf-8');
await writeFile(join(binDir, 'pandoc.cmd'), `@echo off\r\n"${process.execPath}" "%~dp0pandoc.js" %*\r\n`, 'utf-8');
return;
}

const script = join(binDir, 'pandoc');
await writeFile(script, [
'#!/usr/bin/env bash',
Expand Down