From f502045b16ee33232a4721b8695f8d422c78f13b Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 13 Jul 2026 13:36:35 +0530 Subject: [PATCH 1/2] 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. --- packages/cli/src/lib/app/local-dev.ts | 113 +++++++++++++++----------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index e4dc415..700000e 100644 --- a/packages/cli/src/lib/app/local-dev.ts +++ b/packages/cli/src/lib/app/local-dev.ts @@ -1,8 +1,9 @@ // biome-ignore-all lint/performance/noAwaitInLoops: Local app detection and command fallbacks must short-circuit sequentially. // biome-ignore-all lint/performance/useTopLevelRegex: Existing package script regexes are kept inline for readability. -import { type SpawnOptions, spawn } from "node:child_process"; import { access } from "node:fs/promises"; import path from "node:path"; + +import { execa } from "execa"; import type { AppBuildType, ResolvedAppBuildType } from "./build"; import { readBunPackageEntrypoint, @@ -74,17 +75,11 @@ export async function runLocalApp(options: { port: number; env: NodeJS.ProcessEnv; signal?: AbortSignal; - spawnImpl?: typeof spawn; }): Promise { - const spawnImpl = options.spawnImpl ?? spawn; - if (options.buildType === "nextjs") { - const localBin = path.join( - options.appPath, - "node_modules", - ".bin", - process.platform === "win32" ? "next.cmd" : "next", - ); + // execa resolves Windows `.cmd` shims via PATHEXT (cross-spawn), so the + // extensionless bin path works on every platform. + const localBin = path.join(options.appPath, "node_modules", ".bin", "next"); const command = await runWithFallback( [ { @@ -111,7 +106,6 @@ export async function runLocalApp(options: { }, signal: options.signal, }, - spawnImpl, "Could not find the Next.js CLI. Install it with `npm install next` or ensure npx/bunx is available.", ); @@ -146,7 +140,6 @@ export async function runLocalApp(options: { }, signal: options.signal, }, - spawnImpl, "Bun is required to run this app locally. Install it from https://bun.sh.", ); @@ -248,8 +241,11 @@ function hasDependency( } async function runWithFallback( candidates: CommandCandidate[], - options: SpawnOptions, - spawnImpl: typeof spawn, + options: { + cwd: string; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }, missingCommandMessage: string, ): Promise<{ display: string; @@ -257,45 +253,68 @@ async function runWithFallback( signal: NodeJS.Signals | null; }> { for (const candidate of candidates) { - try { - const result = await spawnCommand(candidate, options, spawnImpl); - return { - display: candidate.display, - exitCode: result.exitCode, - signal: result.signal, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - continue; - } - - throw error; + const result = await spawnCommand(candidate, options); + if (result === "unavailable") { + continue; } + return { + display: candidate.display, + exitCode: result.exitCode, + signal: result.signal, + }; } throw new Error(missingCommandMessage); } -function spawnCommand( +/** + * Runs one candidate to completion with inherited stdio, returning its exit + * information, or "unavailable" when the binary does not exist so the ladder + * can try the next candidate. execa spawns through cross-spawn, so Windows + * `.cmd` shims (npx, `node_modules/.bin` entries) resolve via PATHEXT instead + * of failing with a false ENOENT. + */ +async function spawnCommand( candidate: CommandCandidate, - options: SpawnOptions, - spawnImpl: typeof spawn, -): Promise<{ - exitCode: number; - signal: NodeJS.Signals | null; -}> { - return new Promise((resolve, reject) => { - const child = spawnImpl(candidate.command, candidate.args, { - ...options, - stdio: "inherit", - }); - - child.once("error", reject); - child.once("exit", (code, signal) => { - resolve({ - exitCode: code ?? 1, - signal, - }); - }); + options: { + cwd: string; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }, +): Promise< + | "unavailable" + | { + exitCode: number; + signal: NodeJS.Signals | null; + } +> { + // The caller passes the full runtime env; extending over process.env again + // would let parent vars leak past a deliberate omission. + const result = await execa(candidate.command, candidate.args, { + cwd: options.cwd, + env: options.env, + extendEnv: false, + cancelSignal: options.signal, + stdio: "inherit", + reject: false, }); + + if (result.code === "ENOENT") { + return "unavailable"; + } + // Started but did not spawn cleanly for another reason (e.g. EACCES): + // surface it rather than misreporting it as an app exit. + if ( + result.failed && + result.exitCode === undefined && + result.signal === undefined && + !result.isCanceled + ) { + throw new Error(result.shortMessage, { cause: result.cause }); + } + + return { + exitCode: result.exitCode ?? 1, + signal: (result.signal ?? null) as NodeJS.Signals | null, + }; } From 0d2e4a570d8b150885392358b167a61aeca6e4d8 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 13 Jul 2026 14:02:12 +0530 Subject: [PATCH 2/2] chore(cli): trim comments to constraints only --- packages/cli/src/lib/app/local-dev.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index 700000e..604e314 100644 --- a/packages/cli/src/lib/app/local-dev.ts +++ b/packages/cli/src/lib/app/local-dev.ts @@ -270,9 +270,7 @@ async function runWithFallback( /** * Runs one candidate to completion with inherited stdio, returning its exit * information, or "unavailable" when the binary does not exist so the ladder - * can try the next candidate. execa spawns through cross-spawn, so Windows - * `.cmd` shims (npx, `node_modules/.bin` entries) resolve via PATHEXT instead - * of failing with a false ENOENT. + * can try the next candidate. */ async function spawnCommand( candidate: CommandCandidate, @@ -288,10 +286,9 @@ async function spawnCommand( signal: NodeJS.Signals | null; } > { - // The caller passes the full runtime env; extending over process.env again - // would let parent vars leak past a deliberate omission. const result = await execa(candidate.command, candidate.args, { cwd: options.cwd, + // The env is fully constructed by the caller; do not re-merge process.env. env: options.env, extendEnv: false, cancelSignal: options.signal,