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" ;
43import { access } from "node:fs/promises" ;
54import path from "node:path" ;
5+
6+ import { execa } from "execa" ;
67import type { AppBuildType , ResolvedAppBuildType } from "./build" ;
78import {
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}
249242async 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