Skip to content
Open
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
110 changes: 63 additions & 47 deletions packages/cli/src/lib/app/local-dev.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -74,17 +75,11 @@ export async function runLocalApp(options: {
port: number;
env: NodeJS.ProcessEnv;
signal?: AbortSignal;
spawnImpl?: typeof spawn;
}): Promise<LocalRunResult> {
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(
[
{
Expand All @@ -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.",
);

Expand Down Expand Up @@ -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.",
);

Expand Down Expand Up @@ -248,54 +241,77 @@ 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;
exitCode: number;
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,
};
}
Loading