From 74a671a025e1365a4b219ce30284297192f89f27 Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 16:32:30 +0100 Subject: [PATCH 1/4] Fix Windows CLI execution shims --- packages/core/src/exec.ts | 18 +++++++++++++++--- packages/docs/pandoc/src/index.test.ts | 20 ++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index ed4e32a9..2edbc7bf 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -28,11 +28,14 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom } return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { + const spawnOptions = { cwd: opts.cwd, env: { ...process.env, ...extraEnv }, - stdio: ['ignore', 'pipe', 'pipe'], - }); + stdio: 'pipe' as const, + }; + const child = process.platform === 'win32' + ? spawn(windowsCommandLine(cmd, args), { ...spawnOptions, shell: true }) + : spawn(cmd, args, spawnOptions); let stdout = ''; let stderr = ''; @@ -69,6 +72,15 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom }); } +function windowsCommandLine(cmd: string, args: string[]): string { + return [cmd, ...args].map(windowsShellQuote).join(' '); +} + +function windowsShellQuote(value: string): string { + if (/^[A-Za-z0-9_/:=.+@-]+$/.test(value)) return value; + return `"${value.replace(/"/g, '\\"')}"`; +} + export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { try { await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false }); diff --git a/packages/docs/pandoc/src/index.test.ts b/packages/docs/pandoc/src/index.test.ts index 8a149591..b424a2b9 100644 --- a/packages/docs/pandoc/src/index.test.ts +++ b/packages/docs/pandoc/src/index.test.ts @@ -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'; @@ -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', @@ -90,6 +90,22 @@ describe('docs-pandoc generation', () => { }); async function installFakePandoc(binDir: string): Promise { + 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}" "%~dp0\\pandoc.js" %*\r\n`, 'utf-8'); + return; + } + const script = join(binDir, 'pandoc'); await writeFile(script, [ '#!/usr/bin/env bash', From 99b9eee7994b854dc33354e58222605c2123e00c Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 16:47:36 +0100 Subject: [PATCH 2/4] Address Windows exec review --- packages/core/src/exec.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index 2edbc7bf..de37c358 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -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']; @@ -28,10 +29,10 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom } return new Promise((resolve, reject) => { - const spawnOptions = { + const spawnOptions: SpawnOptions = { cwd: opts.cwd, env: { ...process.env, ...extraEnv }, - stdio: 'pipe' as const, + stdio: ['ignore', 'pipe', 'pipe'], }; const child = process.platform === 'win32' ? spawn(windowsCommandLine(cmd, args), { ...spawnOptions, shell: true }) @@ -40,13 +41,13 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom 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'); @@ -78,7 +79,7 @@ function windowsCommandLine(cmd: string, args: string[]): string { function windowsShellQuote(value: string): string { if (/^[A-Za-z0-9_/:=.+@-]+$/.test(value)) return value; - return `"${value.replace(/"/g, '\\"')}"`; + return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/, '$&$&')}"`; } export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { From bb071a9fad25ec043a29c1a35d9345fae56ee399 Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 23:26:45 +0100 Subject: [PATCH 3/4] Address Windows ensureCli review --- packages/core/src/exec.test.ts | 46 ++++++++++++++++++++++++++++++++++ packages/core/src/exec.ts | 12 +++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/exec.test.ts diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts new file mode 100644 index 00000000..a0372e92 --- /dev/null +++ b/packages/core/src/exec.test.ts @@ -0,0 +1,46 @@ +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; + +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 result = await exec(process.execPath, ['-e', 'console.log(process.argv[1])', '%SH1PT_EXEC_LITERAL%'], { + log: () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('%SH1PT_EXEC_LITERAL%'); + }); +}); + +describe('ensureCli', () => { + it('throws when a command exits non-zero instead of reporting it as installed', 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'); + }); +}); + +async function installFailingCli(binDir: string, name: string): Promise { + 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 }); +} diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index de37c358..754c1499 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -34,7 +34,7 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }; - const child = process.platform === 'win32' + const child = shouldUseWindowsShell(cmd) ? spawn(windowsCommandLine(cmd, args), { ...spawnOptions, shell: true }) : spawn(cmd, args, spawnOptions); @@ -73,6 +73,13 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom }); } +function shouldUseWindowsShell(cmd: string): boolean { + return process.platform === 'win32' + && !cmd.includes('/') + && !cmd.includes('\\') + && !/\.(?:exe|com)$/i.test(cmd); +} + function windowsCommandLine(cmd: string, args: string[]): string { return [cmd, ...args].map(windowsShellQuote).join(' '); } @@ -84,7 +91,8 @@ function windowsShellQuote(value: string): string { export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { try { - await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false }); + const result = await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false }); + if (result.exitCode !== 0) 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'); From e8b8dea6562f7485b26d559fd1e55b0a60634071 Mon Sep 17 00:00:00 2001 From: aysko Date: Wed, 27 May 2026 08:14:35 +0100 Subject: [PATCH 4/4] Address Windows cmd argument handling --- packages/core/src/exec.test.ts | 29 +++++++++++++++++++++++++++-- packages/core/src/exec.ts | 15 +++------------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts index a0372e92..6a0d09be 100644 --- a/packages/core/src/exec.test.ts +++ b/packages/core/src/exec.test.ts @@ -14,12 +14,17 @@ afterEach(async () => { describe('exec', () => { it('preserves percent-wrapped arguments on Windows shell execution', async () => { - const result = await exec(process.execPath, ['-e', 'console.log(process.argv[1])', '%SH1PT_EXEC_LITERAL%'], { + 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\\'], { log: () => {}, }); expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('%SH1PT_EXEC_LITERAL%'); + expect(JSON.parse(result.stdout.trim())).toEqual(['%SH1PT_EXEC_LITERAL%', 'C:\\tmp\\path\\']); }); }); @@ -44,3 +49,23 @@ async function installFailingCli(binDir: string, name: string): Promise { 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 { + 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, + }); +} diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index 754c1499..3a41651d 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -34,8 +34,8 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }; - const child = shouldUseWindowsShell(cmd) - ? spawn(windowsCommandLine(cmd, args), { ...spawnOptions, shell: true }) + const child = shouldUseWindowsCmd(cmd) + ? spawn('cmd.exe', ['/d', '/s', '/c', cmd, ...args], spawnOptions) : spawn(cmd, args, spawnOptions); let stdout = ''; @@ -73,22 +73,13 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom }); } -function shouldUseWindowsShell(cmd: string): boolean { +function shouldUseWindowsCmd(cmd: string): boolean { return process.platform === 'win32' && !cmd.includes('/') && !cmd.includes('\\') && !/\.(?:exe|com)$/i.test(cmd); } -function windowsCommandLine(cmd: string, args: string[]): string { - return [cmd, ...args].map(windowsShellQuote).join(' '); -} - -function windowsShellQuote(value: string): string { - if (/^[A-Za-z0-9_/:=.+@-]+$/.test(value)) return value; - return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/, '$&$&')}"`; -} - export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { try { const result = await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false });