From 58c7252bb53f5437000ff5c12ae8ba5ed10cb540 Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 16:32:30 +0100 Subject: [PATCH 1/7] 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 60e0ae3e2bafb3fbc55507f8c04624da2717b382 Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 16:47:36 +0100 Subject: [PATCH 2/7] 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 cdfbaf28ea7f8b3ba0be9f45ed3e2daec83c7a6a Mon Sep 17 00:00:00 2001 From: aysko Date: Tue, 26 May 2026 23:26:45 +0100 Subject: [PATCH 3/7] 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 0cfabf74341c2f51242f31eb9374821ff365999a Mon Sep 17 00:00:00 2001 From: aysko Date: Wed, 27 May 2026 08:14:35 +0100 Subject: [PATCH 4/7] 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 }); From a0eda03bb2795a525ca44134037f3c33dcd1f633 Mon Sep 17 00:00:00 2001 From: aysko Date: Wed, 27 May 2026 16:39:28 +0100 Subject: [PATCH 5/7] Narrow Windows ensureCli missing detection --- packages/core/src/exec.test.ts | 14 +++++++++++++- packages/core/src/exec.ts | 10 +++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts index 6a0d09be..0a27bf51 100644 --- a/packages/core/src/exec.test.ts +++ b/packages/core/src/exec.test.ts @@ -6,6 +6,8 @@ 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; @@ -29,7 +31,7 @@ describe('exec', () => { }); describe('ensureCli', () => { - it('throws when a command exits non-zero instead of reporting it as installed', async () => { + 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'); @@ -38,6 +40,16 @@ describe('ensureCli', () => { 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 { diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index 3a41651d..3d80c98e 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -83,7 +83,7 @@ function shouldUseWindowsCmd(cmd: string): boolean { export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { try { const result = await exec(cmd, ['--version'], { log: () => {}, throwOnNonZero: false }); - if (result.exitCode !== 0) throw new Error(`command not found: ${cmd}`); + 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'); @@ -92,3 +92,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'); +} From 635e7e55913ddf4c01c9ac2003a08a1080863f78 Mon Sep 17 00:00:00 2001 From: aysko Date: Thu, 28 May 2026 10:30:04 +0100 Subject: [PATCH 6/7] Escape Windows cmd shim arguments --- packages/core/src/exec.test.ts | 1 + packages/core/src/exec.ts | 19 ++++++++++++++++++- packages/docs/pandoc/src/index.test.ts | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts index 0a27bf51..9ac5cf11 100644 --- a/packages/core/src/exec.test.ts +++ b/packages/core/src/exec.test.ts @@ -22,6 +22,7 @@ describe('exec', () => { process.env.PATH = `${binDir}${delimiter}${oldPath ?? ''}`; const result = await exec('sh1pt-echo-args', ['%SH1PT_EXEC_LITERAL%', 'C:\\tmp\\path\\'], { + env: { SH1PT_EXEC_LITERAL: 'expanded-value' }, log: () => {}, }); diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index 3d80c98e..a308d792 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -35,7 +35,10 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom stdio: ['ignore', 'pipe', 'pipe'], }; const child = shouldUseWindowsCmd(cmd) - ? spawn('cmd.exe', ['/d', '/s', '/c', cmd, ...args], spawnOptions) + ? spawn('cmd.exe', ['/d', '/s', '/c', windowsCommandLine(cmd, args)], { + ...spawnOptions, + windowsVerbatimArguments: true, + }) : spawn(cmd, args, spawnOptions); let stdout = ''; @@ -80,6 +83,20 @@ function shouldUseWindowsCmd(cmd: string): boolean { && !/\.(?:exe|com)$/i.test(cmd); } +function windowsCommandLine(cmd: string, args: string[]): string { + return [cmd, ...args].map(windowsCmdArg).join(' '); +} + +function windowsCmdArg(value: string): string { + let escaped = value.replace(/([%^&|<>()])/g, '^$1'); + if (!/[\s"]/.test(escaped)) return escaped; + + escaped = escaped + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/\\+$/, '$&$&'); + return `"${escaped}"`; +} + export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise { try { const result = 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 b424a2b9..82df2528 100644 --- a/packages/docs/pandoc/src/index.test.ts +++ b/packages/docs/pandoc/src/index.test.ts @@ -102,7 +102,7 @@ async function installFakePandoc(binDir: string): Promise { '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'); + await writeFile(join(binDir, 'pandoc.cmd'), `@echo off\r\n"${process.execPath}" "%~dp0pandoc.js" %*\r\n`, 'utf-8'); return; } From b8b50e74c7ebc455348b140314514f153bd309dd Mon Sep 17 00:00:00 2001 From: aysko Date: Thu, 28 May 2026 13:39:31 +0100 Subject: [PATCH 7/7] Preserve Windows shim metachar args --- packages/core/src/exec.test.ts | 16 ++++++++++++++-- packages/core/src/exec.ts | 34 +++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts index 9ac5cf11..e647aaea 100644 --- a/packages/core/src/exec.test.ts +++ b/packages/core/src/exec.test.ts @@ -21,13 +21,25 @@ describe('exec', () => { 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\\'], { + 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\\']); + expect(JSON.parse(result.stdout.trim())).toEqual([ + '%SH1PT_EXEC_LITERAL%', + 'C:\\tmp\\path\\', + 'Foo & Bar', + 'hello!world', + 'quoted "value"', + ]); }); }); diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index a308d792..aa714a2d 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -34,9 +34,14 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }; - const child = shouldUseWindowsCmd(cmd) - ? spawn('cmd.exe', ['/d', '/s', '/c', windowsCommandLine(cmd, args)], { + 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); @@ -83,18 +88,25 @@ function shouldUseWindowsCmd(cmd: string): boolean { && !/\.(?:exe|com)$/i.test(cmd); } -function windowsCommandLine(cmd: string, args: string[]): string { - return [cmd, ...args].map(windowsCmdArg).join(' '); +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 windowsCmdArg(value: string): string { - let escaped = value.replace(/([%^&|<>()])/g, '^$1'); - if (!/[\s"]/.test(escaped)) return escaped; - - escaped = escaped +function windowsEnvArg(value: string): string { + return value .replace(/(\\*)"/g, '$1$1\\"') - .replace(/\\+$/, '$&$&'); - return `"${escaped}"`; + .replace(/\\+$/, '$&$&') + .replace(/!/g, '^!'); } export async function ensureCli(cmd: string, installHint: string, log: LogFn): Promise {