Skip to content

Commit f502045

Browse files
feat(cli): run app dev candidates through execa
Migrates the local dev-server launcher ladder from raw node:child_process spawn to execa (already a CLI dependency). execa spawns through cross-spawn, so on Windows the npx rung resolves its .cmd shim via PATHEXT instead of always failing with a false ENOENT, which made the Next.js ladder bunx-or-bust there. The hand-rolled win32 next.cmd special case goes away for the same reason. Behavior preserved: inherited stdio, ENOENT falls through to the next candidate, exit code and termination signal are reported (SIGTERM on abort still maps to the controller's cancel path), and the child env is passed exactly (extendEnv: false). Spawn failures other than ENOENT now surface with execa's message instead of a raw errno throw. Drops the unused spawnImpl injection seam; tests mock runLocalApp itself. Verified live: bun --watch runs and terminates cleanly on abort, and an all-ENOENT ladder still produces the friendly install message.
1 parent a589fdd commit f502045

1 file changed

Lines changed: 66 additions & 47 deletions

File tree

packages/cli/src/lib/app/local-dev.ts

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// biome-ignore-all lint/performance/noAwaitInLoops: Local app detection and command fallbacks must short-circuit sequentially.
22
// biome-ignore-all lint/performance/useTopLevelRegex: Existing package script regexes are kept inline for readability.
3-
import { type SpawnOptions, spawn } from "node:child_process";
43
import { access } from "node:fs/promises";
54
import path from "node:path";
5+
6+
import { execa } from "execa";
67
import type { AppBuildType, ResolvedAppBuildType } from "./build";
78
import {
89
readBunPackageEntrypoint,
@@ -74,17 +75,11 @@ export async function runLocalApp(options: {
7475
port: number;
7576
env: NodeJS.ProcessEnv;
7677
signal?: AbortSignal;
77-
spawnImpl?: typeof spawn;
7878
}): Promise<LocalRunResult> {
79-
const spawnImpl = options.spawnImpl ?? spawn;
80-
8179
if (options.buildType === "nextjs") {
82-
const localBin = path.join(
83-
options.appPath,
84-
"node_modules",
85-
".bin",
86-
process.platform === "win32" ? "next.cmd" : "next",
87-
);
80+
// execa resolves Windows `.cmd` shims via PATHEXT (cross-spawn), so the
81+
// extensionless bin path works on every platform.
82+
const localBin = path.join(options.appPath, "node_modules", ".bin", "next");
8883
const command = await runWithFallback(
8984
[
9085
{
@@ -111,7 +106,6 @@ export async function runLocalApp(options: {
111106
},
112107
signal: options.signal,
113108
},
114-
spawnImpl,
115109
"Could not find the Next.js CLI. Install it with `npm install next` or ensure npx/bunx is available.",
116110
);
117111

@@ -146,7 +140,6 @@ export async function runLocalApp(options: {
146140
},
147141
signal: options.signal,
148142
},
149-
spawnImpl,
150143
"Bun is required to run this app locally. Install it from https://bun.sh.",
151144
);
152145

@@ -248,54 +241,80 @@ function hasDependency(
248241
}
249242
async function runWithFallback(
250243
candidates: CommandCandidate[],
251-
options: SpawnOptions,
252-
spawnImpl: typeof spawn,
244+
options: {
245+
cwd: string;
246+
env: NodeJS.ProcessEnv;
247+
signal?: AbortSignal;
248+
},
253249
missingCommandMessage: string,
254250
): Promise<{
255251
display: string;
256252
exitCode: number;
257253
signal: NodeJS.Signals | null;
258254
}> {
259255
for (const candidate of candidates) {
260-
try {
261-
const result = await spawnCommand(candidate, options, spawnImpl);
262-
return {
263-
display: candidate.display,
264-
exitCode: result.exitCode,
265-
signal: result.signal,
266-
};
267-
} catch (error) {
268-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
269-
continue;
270-
}
271-
272-
throw error;
256+
const result = await spawnCommand(candidate, options);
257+
if (result === "unavailable") {
258+
continue;
273259
}
260+
return {
261+
display: candidate.display,
262+
exitCode: result.exitCode,
263+
signal: result.signal,
264+
};
274265
}
275266

276267
throw new Error(missingCommandMessage);
277268
}
278269

279-
function spawnCommand(
270+
/**
271+
* Runs one candidate to completion with inherited stdio, returning its exit
272+
* information, or "unavailable" when the binary does not exist so the ladder
273+
* can try the next candidate. execa spawns through cross-spawn, so Windows
274+
* `.cmd` shims (npx, `node_modules/.bin` entries) resolve via PATHEXT instead
275+
* of failing with a false ENOENT.
276+
*/
277+
async function spawnCommand(
280278
candidate: CommandCandidate,
281-
options: SpawnOptions,
282-
spawnImpl: typeof spawn,
283-
): Promise<{
284-
exitCode: number;
285-
signal: NodeJS.Signals | null;
286-
}> {
287-
return new Promise((resolve, reject) => {
288-
const child = spawnImpl(candidate.command, candidate.args, {
289-
...options,
290-
stdio: "inherit",
291-
});
292-
293-
child.once("error", reject);
294-
child.once("exit", (code, signal) => {
295-
resolve({
296-
exitCode: code ?? 1,
297-
signal,
298-
});
299-
});
279+
options: {
280+
cwd: string;
281+
env: NodeJS.ProcessEnv;
282+
signal?: AbortSignal;
283+
},
284+
): Promise<
285+
| "unavailable"
286+
| {
287+
exitCode: number;
288+
signal: NodeJS.Signals | null;
289+
}
290+
> {
291+
// The caller passes the full runtime env; extending over process.env again
292+
// would let parent vars leak past a deliberate omission.
293+
const result = await execa(candidate.command, candidate.args, {
294+
cwd: options.cwd,
295+
env: options.env,
296+
extendEnv: false,
297+
cancelSignal: options.signal,
298+
stdio: "inherit",
299+
reject: false,
300300
});
301+
302+
if (result.code === "ENOENT") {
303+
return "unavailable";
304+
}
305+
// Started but did not spawn cleanly for another reason (e.g. EACCES):
306+
// surface it rather than misreporting it as an app exit.
307+
if (
308+
result.failed &&
309+
result.exitCode === undefined &&
310+
result.signal === undefined &&
311+
!result.isCanceled
312+
) {
313+
throw new Error(result.shortMessage, { cause: result.cause });
314+
}
315+
316+
return {
317+
exitCode: result.exitCode ?? 1,
318+
signal: (result.signal ?? null) as NodeJS.Signals | null,
319+
};
301320
}

0 commit comments

Comments
 (0)