@@ -13,6 +13,7 @@ import {
1313import { tmpdir } from "node:os" ;
1414import { delimiter , join } from "node:path" ;
1515import process from "node:process" ;
16+ import { pathToFileURL } from "node:url" ;
1617import { afterEach , beforeEach , describe , expect , it } from "vitest" ;
1718
1819import {
@@ -191,6 +192,23 @@ async function listing(directory: string): Promise<string[]> {
191192}
192193const RELEASE = { install : { launchers : { "@taskless/cli" : "0.13.0" } } } ;
193194
195+ /** One argument as the launcher quotes it for a batch file such as npm.cmd. */
196+ function quoted ( inner : string ) : string {
197+ return `^^^"${ inner } ^^^"` ;
198+ }
199+
200+ /**
201+ * A stand-in for cmd.exe: it logs the arguments it was handed, then does what
202+ * the real one would, which is to run npm, here the fake one.
203+ */
204+ const FAKE_CMD = String . raw `#!/usr/bin/env node
205+ const fs = require("node:fs");
206+ const { spawnSync } = require("node:child_process");
207+ fs.appendFileSync(process.env.FAKE_CMD_LOG, JSON.stringify(process.argv.slice(2)) + "\n");
208+ const npm = spawnSync(process.execPath, [process.env.FAKE_NPM_PATH, "ci"], { stdio: "inherit" });
209+ process.exit(npm.status ?? 1);
210+ ` ;
211+
194212describe . skipIf ( process . platform === "win32" ) ( "project launcher" , ( ) => {
195213 it ( "installs the recorded version once, then runs it with every argument" , async ( ) => {
196214 const path = await install ( "@taskless/cli" , RELEASE , "0.13.0" ) ;
@@ -272,6 +290,70 @@ describe.skipIf(process.platform === "win32")("project launcher", () => {
272290 ) . toBe ( 1 ) ;
273291 } ) ;
274292
293+ it ( "quotes npm's arguments for cmd.exe on Windows, and passes the CLI's untouched" , async ( ) => {
294+ const path = await install ( "@taskless/cli" , RELEASE , "0.13.0" ) ;
295+ // Pretend to be Windows, and stand in for cmd.exe, so the quoting runs
296+ // anywhere. npm.cmd is a batch file and goes through cmd.exe; the CLI is
297+ // started with node directly, so its arguments are never quoted.
298+ const preload = join ( root , "as-windows.mjs" ) ;
299+ await writeFile (
300+ preload ,
301+ 'Object.defineProperty(process, "platform", { value: "win32" });\n'
302+ ) ;
303+ const comspec = join ( fakeBin , "cmd" ) ;
304+ await writeFile ( comspec , FAKE_CMD ) ;
305+ await chmod ( comspec , 0o755 ) ;
306+ const cmdLog = join ( root , "cmd.log" ) ;
307+
308+ const result = spawnSync (
309+ process . execPath ,
310+ [
311+ "--import" ,
312+ pathToFileURL ( preload ) . href ,
313+ path ,
314+ "check" ,
315+ "src/a file.ts" ,
316+ 'say "hi" & calc' ,
317+ "C:\\dir\\" ,
318+ ] ,
319+ {
320+ cwd : root ,
321+ encoding : "utf8" ,
322+ env : {
323+ ...process . env ,
324+ comspec,
325+ FAKE_CMD_LOG : cmdLog ,
326+ FAKE_NPM_PATH : join ( fakeBin , "npm" ) ,
327+ FAKE_NPM_LOG : npmLog ,
328+ FAKE_CLI_SOURCE : FAKE_CLI ,
329+ } ,
330+ }
331+ ) ;
332+ expect ( result . status ) . toBe ( 0 ) ;
333+ const calls = await readFile ( cmdLog , "utf8" ) ;
334+ // Each argument is quoted, then every metacharacter is escaped twice:
335+ // once for cmd.exe and once for the batch file's own parse.
336+ expect ( JSON . parse ( calls . trim ( ) ) ) . toEqual ( [
337+ "/d" ,
338+ "/s" ,
339+ "/c" ,
340+ `"${ [
341+ "npm" ,
342+ quoted ( "ci" ) ,
343+ quoted ( "--ignore-scripts" ) ,
344+ quoted ( "--no-audit" ) ,
345+ quoted ( "--no-fund" ) ,
346+ quoted ( "--loglevel=error" ) ,
347+ ] . join ( " " ) } "`,
348+ ] ) ;
349+ expect ( reported ( result . stdout ) . argv ) . toEqual ( [
350+ "check" ,
351+ "src/a file.ts" ,
352+ 'say "hi" & calc' ,
353+ "C:\\dir\\" ,
354+ ] ) ;
355+ } ) ;
356+
275357 it ( "ends by the same signal that ended the CLI" , async ( ) => {
276358 const path = await install ( "@taskless/cli" , RELEASE , "0.13.0" ) ;
277359 const result = launch ( path , [ "check" ] , {
0 commit comments