Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-upgrade-command-spawning.md
Original file line number Diff line number Diff line change
@@ -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`
53 changes: 36 additions & 17 deletions packages/upgrade/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[],
Expand All @@ -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<void>((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 };
}
Loading