diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts new file mode 100644 index 00000000..6a0d09be --- /dev/null +++ b/packages/core/src/exec.test.ts @@ -0,0 +1,71 @@ +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 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(JSON.parse(result.stdout.trim())).toEqual(['%SH1PT_EXEC_LITERAL%', 'C:\\tmp\\path\\']); + }); +}); + +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 }); +} + +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 ed4e32a9..3a41651d 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,22 +29,25 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom } return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { + const spawnOptions: SpawnOptions = { cwd: opts.cwd, env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], - }); + }; + const child = shouldUseWindowsCmd(cmd) + ? spawn('cmd.exe', ['/d', '/s', '/c', cmd, ...args], spawnOptions) + : 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'); @@ -69,9 +73,17 @@ 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); +} + 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'); 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',