diff --git a/packages/cli/src/lib/app/local-dev.ts b/packages/cli/src/lib/app/local-dev.ts index e4dc415..604e314 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,65 @@ 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. + */ +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; + } +> { + 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, + 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, + }; }