diff --git a/.changeset/harden-upgrade-command-spawning.md b/.changeset/harden-upgrade-command-spawning.md new file mode 100644 index 000000000000..3994747115ed --- /dev/null +++ b/.changeset/harden-upgrade-command-spawning.md @@ -0,0 +1,5 @@ +--- +'@astrojs/upgrade': patch +--- + +Improves how `@astrojs/upgrade` spawns package manager commands so it uses the same Windows command resolution as `create-astro` diff --git a/packages/upgrade/src/shell.ts b/packages/upgrade/src/shell.ts index 0e4065108baf..7abad5fffcd8 100644 --- a/packages/upgrade/src/shell.ts +++ b/packages/upgrade/src/shell.ts @@ -5,6 +5,10 @@ import { spawn } from 'node:child_process'; import type { Readable } from 'node:stream'; import { text as textFromStream } from 'node:stream/consumers'; +const WINDOWS_CMD_SHIMS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'yarnpkg']); +// Bun ships as .exe on Windows, not .cmd, so it can be spawned directly with the .exe extension +const WINDOWS_EXE_SHIMS = new Set(['bun', 'bunx']); + interface ExecaOptions { cwd?: string | URL; stdio?: StdioOptions; @@ -18,7 +22,27 @@ interface Output { const text = (stream: NodeJS.ReadableStream | Readable | null) => stream ? textFromStream(stream).then((t) => t.trimEnd()) : ''; -let signal: AbortSignal; +/** + * On Windows, `.cmd` and `.bat` files cannot be spawned directly without a shell. + * For known package manager shims, we invoke them via `cmd.exe /d /s /c` instead. + * Bun ships as `.exe` on Windows (not `.cmd`), so it can be spawned directly. + * Returns [resolvedCommand, resolvedFlags] to use with spawn. + */ +function resolveCommand(command: string, flags: string[]): [string, string[]] { + if (process.platform !== 'win32') return [command, flags]; + if (command.includes('/') || command.includes('\\') || command.includes('.')) { + return [command, flags]; + } + const cmd = command.toLowerCase(); + if (WINDOWS_CMD_SHIMS.has(cmd)) { + return ['cmd.exe', ['/d', '/s', '/c', `${command}.cmd`, ...flags]]; + } + if (WINDOWS_EXE_SHIMS.has(cmd)) { + return [`${command}.exe`, flags]; + } + return [command, flags]; +} + export async function shell( command: string, flags: string[], @@ -27,33 +51,28 @@ export async function shell( let child: ChildProcess; let stdout = ''; let stderr = ''; - if (!signal) { - const controller = new AbortController(); - // Ensure spawned process is cancelled on exit - process.once('beforeexit', () => controller.abort()); - process.once('exit', () => controller.abort()); - signal = controller.signal; - } try { - child = spawn(`${command} ${flags.join(' ')}`, { + const [resolvedCommand, resolvedFlags] = resolveCommand(command, flags); + child = spawn(resolvedCommand, resolvedFlags, { cwd: opts.cwd, - shell: true, stdio: opts.stdio, timeout: opts.timeout, - signal, }); - const done = new Promise((resolve) => child.on('close', resolve)); - [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr)]); - await done; - } catch { - throw { stdout, stderr, exitCode: 1 }; + const done = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', () => resolve()); + }); + [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr), done]); + } catch (e) { + const message = e instanceof Error ? e.message : stderr || 'Unknown error'; + throw new Error(message); } const { exitCode } = child; if (exitCode === null) { throw new Error('Timeout'); } if (exitCode !== 0) { - throw new Error(stderr); + throw new Error(stderr || `Process exited with code ${exitCode}`); } return { stdout, stderr, exitCode }; }