From e1e40e92cff04278453dc17fba9a6ef3d10ee106 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 17:36:14 -0700 Subject: [PATCH 1/2] fix: detect PATH-shadowed failproofai installs at install + run time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale `bun link` from a contributor's prior `bun run dev` (or a `bun install -g failproofai` whose ~/.bun/bin sorts ahead of npm's prefix) shadows later `npm install -g failproofai@` updates. Customers then see a confusing "Cannot find server.js at: /.next/standalone/server.js" runtime error pointing at the shadowed install — and the recommended fix (`npm install -g failproofai@latest`) doesn't help when the new install is itself being shadowed. New scripts/install-diagnosis.mjs resolves the PATH-first failproofai via `command -v` (POSIX) / `where` (Win32), compares its package root + version against the running install, and emits a copy-pasteable cleanup command. Surfaced in two places: scripts/postinstall.mjs warns at install time before any broken run, and scripts/launch.ts rewrites the existing missing-server.js error to name the actual stale install when the cause is a shadow rather than a broken build. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 3 + __tests__/scripts/install-diagnosis.test.ts | 293 ++++++++++++++++++++ scripts/install-diagnosis.mjs | 181 ++++++++++++ scripts/launch.ts | 21 ++ scripts/postinstall.mjs | 25 ++ 5 files changed, 523 insertions(+) create mode 100644 __tests__/scripts/install-diagnosis.test.ts create mode 100644 scripts/install-diagnosis.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a1074b15..8ad121ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Fixes +- Detect when `failproofai` on the user's PATH is shadowed by a different, older install (classic cause: a leftover `bun link` from a prior dev session, or a previously-installed `bun install -g failproofai` whose `~/.bun/bin` prefix sorts ahead of npm's). New `scripts/install-diagnosis.mjs` helper resolves the PATH-first install via `command -v` (POSIX) / `where` (Win32), compares its package root + version against the running install, and surfaces a copy-pasteable cleanup command (`rm -f ~/.bun/bin/failproofai && rm -rf ~/.bun/install/global/node_modules/failproofai` for bun-side shadows, `npm rm -g failproofai` for npm-side ones). Wired into two places: (1) `scripts/postinstall.mjs` warns at install time when the just-installed copy is being shadowed, before the customer ever sees the runtime error, (2) `scripts/launch.ts` rewrites the existing "Cannot find server.js at" error to point at the actual stale install (with both versions and the cleanup command) when the missing build output is caused by a PATH shadow rather than a genuinely broken build. Replaces the previous misleading recommendation (`npm install -g failproofai@latest`) which doesn't help when the new install is itself being shadowed (#286). + ## 0.0.10-beta.0 — 2026-05-04 ### Features diff --git a/__tests__/scripts/install-diagnosis.test.ts b/__tests__/scripts/install-diagnosis.test.ts new file mode 100644 index 00000000..43179823 --- /dev/null +++ b/__tests__/scripts/install-diagnosis.test.ts @@ -0,0 +1,293 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const FAKE_HOME = "/fake/home"; + +const NPM_GLOBAL_ROOT = "/usr/lib/node_modules"; +const NPM_GLOBAL_PKG = `${NPM_GLOBAL_ROOT}/failproofai`; +const BUN_GLOBAL_PKG = `${FAKE_HOME}/.bun/install/global/node_modules/failproofai`; +const BUN_BIN_REAL_TARGET = `${FAKE_HOME}/prs/failproofai-old/dist/cli.mjs`; +const BUN_LINKED_PKG = `${FAKE_HOME}/prs/failproofai-old`; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + realpathSync: vi.fn((p: string) => p), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => FAKE_HOME), + platform: vi.fn(() => "linux"), +})); + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +/** + * Helper: build an existsSync responder driven by a set of paths that "exist". + * Anything not in the set returns false. + */ +function existsForPaths(paths: Set) { + return (p: unknown) => (typeof p === "string" ? paths.has(p) : false); +} + +/** + * Helper: build a readFileSync responder for known package.json paths. + * Throws ENOENT-equivalent for paths not in the map. + */ +function readForPackageJsons(map: Map) { + return (p: unknown) => { + if (typeof p !== "string") throw new Error("ENOENT"); + if (map.has(p)) return JSON.stringify(map.get(p)); + throw new Error(`ENOENT: ${p}`); + }; +} + +describe("diagnoseShadow", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns shadowed=false when PATH resolves to the same install as self", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => p); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${NPM_GLOBAL_PKG}/dist/cli.mjs\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + + expect(diag.shadowed).toBe(false); + expect(diag.recommendation).toBeNull(); + expect(diag.pathFirstPath).toBe(NPM_GLOBAL_PKG); + expect(diag.pathFirstVersion).toBe("0.0.10-beta.0"); + }); + + it("flags shadow when a bun-linked dev tree wins on PATH ahead of the just-installed npm copy", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + // bun bin symlink → bun-installed cli.mjs → realpath into the dev tree + vi.mocked(realpathSync).mockImplementation((p: any) => { + if (p === `${FAKE_HOME}/.bun/bin/failproofai`) return BUN_BIN_REAL_TARGET; + return p; + }); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${BUN_LINKED_PKG}/package.json`, + `${BUN_LINKED_PKG}/dist`, + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + // realpath result is a file inside the dev tree + BUN_BIN_REAL_TARGET, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${BUN_LINKED_PKG}/package.json`, { name: "failproofai", version: "0.0.9-beta.3" }], + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${FAKE_HOME}/.bun/bin/failproofai\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + + expect(diag.shadowed).toBe(true); + expect(diag.pathFirstPath).toBe(BUN_LINKED_PKG); + expect(diag.pathFirstVersion).toBe("0.0.9-beta.3"); + expect(diag.npmGlobalPath).toBe(NPM_GLOBAL_PKG); + expect(diag.npmGlobalVersion).toBe("0.0.10-beta.0"); + expect(diag.shadowDescription).toContain("0.0.9-beta.3"); + expect(diag.shadowDescription).toContain("0.0.10-beta.0"); + }); + + it("recommends `rm ~/.bun/bin/...` when the shadow lives under ~/.bun", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => { + if (p === `${FAKE_HOME}/.bun/bin/failproofai`) return `${BUN_GLOBAL_PKG}/dist/cli.mjs`; + return p; + }); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${BUN_GLOBAL_PKG}/package.json`, + BUN_GLOBAL_PKG, + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + `${BUN_GLOBAL_PKG}/dist/cli.mjs`, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${BUN_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.9" }], + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${FAKE_HOME}/.bun/bin/failproofai\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + + expect(diag.shadowed).toBe(true); + expect(diag.recommendation).toContain("~/.bun/bin/failproofai"); + expect(diag.recommendation).toContain("rm"); + }); + + it("recommends `npm rm -g failproofai` when the shadow is an npm install", async () => { + const SECONDARY_NPM_PKG = "/opt/homebrew/lib/node_modules/failproofai"; + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => p); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${SECONDARY_NPM_PKG}/package.json`, + SECONDARY_NPM_PKG, + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${SECONDARY_NPM_PKG}/package.json`, { name: "failproofai", version: "0.0.8" }], + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${SECONDARY_NPM_PKG}/dist/cli.mjs\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + + expect(diag.shadowed).toBe(true); + expect(diag.recommendation).toBe("npm rm -g failproofai"); + }); + + it("returns shadowed=false when `command -v` finds nothing", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => p); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any) => { + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + // command -v fails — failproofai not on PATH + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + + expect(diag.shadowed).toBe(false); + expect(diag.pathFirstPath).toBeNull(); + expect(diag.recommendation).toBeNull(); + }); + + it("treats unreadable package.json as null version without throwing", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => p); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + `${BUN_GLOBAL_PKG}/package.json`, + BUN_GLOBAL_PKG, + ]))); + vi.mocked(readFileSync).mockImplementation((p: unknown) => { + if (p === `${NPM_GLOBAL_PKG}/package.json`) return JSON.stringify({ name: "failproofai", version: "0.0.10-beta.0" }); + if (p === `${BUN_GLOBAL_PKG}/package.json`) return "{ this is not valid json"; + throw new Error("ENOENT"); + }); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${BUN_GLOBAL_PKG}/dist/cli.mjs\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + expect(() => + diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }) + ).not.toThrow(); + const diag = diagnoseShadow({ selfPackageRoot: NPM_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + // Corrupt package.json on the bun side means we cannot identify it as failproofai + // → the walk-up keeps going and may fail to resolve a package root, which is fine: + // shadow detection requires both sides to be identifiable. + expect(diag.pathFirstVersion).toBeNull(); + }); + + it("reports `npm root -g` failure as no npm install found, never throws", async () => { + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => p); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${BUN_GLOBAL_PKG}/package.json`, + BUN_GLOBAL_PKG, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${BUN_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${BUN_GLOBAL_PKG}/dist/cli.mjs\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 1, stdout: "", stderr: "npm not found", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + const diag = diagnoseShadow({ selfPackageRoot: BUN_GLOBAL_PKG, selfVersion: "0.0.10-beta.0" }); + expect(diag.npmGlobalPath).toBeNull(); + expect(diag.shadowed).toBe(false); + }); +}); diff --git a/scripts/install-diagnosis.mjs b/scripts/install-diagnosis.mjs new file mode 100644 index 00000000..5b26d671 --- /dev/null +++ b/scripts/install-diagnosis.mjs @@ -0,0 +1,181 @@ +/** + * Detects when `failproofai` on the user's PATH is shadowed by a different, + * older install — typically a leftover `bun link` from a prior dev session, or + * a `bun install -g failproofai` whose prefix sorts ahead of npm's on PATH. + * + * Used by: + * - scripts/postinstall.mjs — warn at install time so the customer never sees + * the misleading "missing build output" runtime error. + * - scripts/launch.ts — when .next/standalone/server.js is missing, + * produce a shadow-shaped error if the cause is a shadow rather than a + * genuinely broken build. + * + * Pure Node.js built-ins, no external dependencies. Every probe is wrapped in + * try/catch — diagnoseShadow() is guaranteed not to throw. + */ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { homedir, platform } from "node:os"; +import { spawnSync } from "node:child_process"; + +const PKG_NAME = "failproofai"; + +/** + * Walk up from `start` looking for a package.json whose name === "failproofai". + * Returns its directory, or null when no such package.json is reachable. + */ +function findPackageRoot(start) { + try { + let dir = realpathSync(start); + // If `start` was a file (e.g. /usr/local/bin/failproofai), step up to its dir. + if (existsSync(dir) && !existsSync(resolve(dir, "package.json"))) { + dir = dirname(dir); + } + while (true) { + const pkgPath = resolve(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.name === PKG_NAME) return dir; + } catch { + // unreadable or non-JSON — fall through to parent + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } catch { + return null; + } +} + +/** Read `version` from a package.json; null on any error. */ +function readPackageVersion(packageRoot) { + if (!packageRoot) return null; + try { + const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +/** Find which `failproofai` PATH would resolve. POSIX: `command -v`; Win32: `where`. */ +function resolvePathFirstBinary() { + try { + const isWin = platform() === "win32"; + const res = isWin + ? spawnSync("where", [PKG_NAME], { encoding: "utf8" }) + : spawnSync("sh", ["-c", `command -v ${PKG_NAME}`], { encoding: "utf8" }); + if (res.status !== 0) return null; + const first = (res.stdout || "").split(/\r?\n/).find((l) => l.trim().length > 0); + return first ? first.trim() : null; + } catch { + return null; + } +} + +/** Locate the npm global install of failproofai, if any. */ +function locateNpmGlobal() { + try { + const res = spawnSync("npm", ["root", "-g"], { encoding: "utf8" }); + if (res.status !== 0) return null; + const root = (res.stdout || "").trim(); + if (!root) return null; + const candidate = resolve(root, PKG_NAME); + return existsSync(resolve(candidate, "package.json")) ? candidate : null; + } catch { + return null; + } +} + +/** Locate the bun global install of failproofai, if any. */ +function locateBunGlobal() { + try { + const candidate = resolve(homedir(), ".bun", "install", "global", "node_modules", PKG_NAME); + return existsSync(resolve(candidate, "package.json")) ? candidate : null; + } catch { + return null; + } +} + +/** + * Build a copy-pasteable cleanup command for the offending install. + * Bun-link symlinks live under ~/.bun — we recommend `bun unlink` (cleaner than `rm`) + * plus removing the bin symlink. For npm, plain `npm rm -g` is enough. + */ +function buildRecommendation(pathFirstPackageRoot) { + if (!pathFirstPackageRoot) return null; + const isBun = + pathFirstPackageRoot.includes(`${homedir()}/.bun/`) || + pathFirstPackageRoot.includes("/.bun/install/global/"); + if (isBun) { + return `rm -f ~/.bun/bin/${PKG_NAME} && rm -rf ~/.bun/install/global/node_modules/${PKG_NAME}`; + } + return `npm rm -g ${PKG_NAME}`; +} + +/** + * Diagnose whether the running binary is being shadowed on PATH by a different + * failproofai install. + * + * @param {{ selfPackageRoot: string, selfVersion: string | null }} self + * The package root and version of the binary calling diagnoseShadow(). + * Callers (bin/failproofai.mjs, scripts/postinstall.mjs) already have these + * values; passing them in keeps the helper deterministic and free of + * import.meta.url assumptions. + */ +export function diagnoseShadow(self) { + const selfPackageRoot = (() => { + try { return self?.selfPackageRoot ? realpathSync(self.selfPackageRoot) : null; } + catch { return self?.selfPackageRoot ?? null; } + })(); + const selfVersion = self?.selfVersion ?? null; + + const pathFirstBin = resolvePathFirstBinary(); + const pathFirstPackageRoot = pathFirstBin ? findPackageRoot(pathFirstBin) : null; + const pathFirstVersion = readPackageVersion(pathFirstPackageRoot); + + const npmGlobalPath = locateNpmGlobal(); + const npmGlobalVersion = readPackageVersion(npmGlobalPath); + + const bunGlobalPath = locateBunGlobal(); + const bunGlobalVersion = readPackageVersion(bunGlobalPath); + + // "Shadow" = PATH resolves to a different package root than `self`, OR (when + // selfVersion is known) PATH-resolved version disagrees with self's version. + // We only flag it when we actually found a PATH copy AND a self copy to compare. + let shadowed = false; + if (selfPackageRoot && pathFirstPackageRoot) { + if (pathFirstPackageRoot !== selfPackageRoot) shadowed = true; + else if (selfVersion && pathFirstVersion && pathFirstVersion !== selfVersion) shadowed = true; + } + + const recommendation = shadowed ? buildRecommendation(pathFirstPackageRoot) : null; + + // A short human-readable summary used by callers that want a one-liner. + let shadowDescription = null; + if (shadowed) { + shadowDescription = + `PATH resolves to ${pathFirstPackageRoot}` + + (pathFirstVersion ? ` (v${pathFirstVersion})` : "") + + `, but you just installed ${selfPackageRoot}` + + (selfVersion ? ` (v${selfVersion})` : "") + "."; + } + + return { + selfPackageRoot, + selfVersion, + pathFirstBin, + pathFirstPath: pathFirstPackageRoot, + pathFirstVersion, + npmGlobalPath, + npmGlobalVersion, + bunGlobalPath, + bunGlobalVersion, + shadowed, + shadowDescription, + recommendation, + }; +} diff --git a/scripts/launch.ts b/scripts/launch.ts index 7a4a89ce..aa2b0a85 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -7,6 +7,7 @@ import { realpathSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parseScriptArgs } from "./parse-script-args"; +import { diagnoseShadow } from "./install-diagnosis.mjs"; import { version } from "../package.json"; export function launch(mode: "dev" | "start"): void { @@ -49,7 +50,27 @@ export function launch(mode: "dev" | "start"): void { ?? resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), ".."); const serverJsPath = resolve(packageRoot, ".next/standalone/server.js"); if (!existsSync(serverJsPath)) { + // Most "missing server.js" reports come from a PATH shadow (an older + // `bun link` or a `bun install -g` whose prefix wins over npm), not from + // a genuinely broken build. Diagnose first so the error message names + // the actual cause when that's what's going on. + let shadowMessage: string | null = null; + try { + const diag = diagnoseShadow({ selfPackageRoot: packageRoot, selfVersion: version }); + if (diag.shadowed) { + const newer = diag.npmGlobalPath ?? diag.bunGlobalPath ?? "(unknown)"; + const newerVer = diag.npmGlobalVersion ?? diag.bunGlobalVersion ?? "?"; + shadowMessage = + `\nError: failproofai on your PATH is a stale install that no longer has its build output.\n` + + ` Running: ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n` + + ` Newer copy: ${newer} (v${newerVer})\n\n` + + `Remove the shadow with:\n ${diag.recommendation}\n`; + } + } catch { + // Diagnosis is best-effort; fall back to the original message. + } console.error( + shadowMessage ?? `\nError: Cannot find server.js at:\n ${serverJsPath}\n\n` + `The package may be missing its build output.\n` + `Try reinstalling:\n npm install -g failproofai@latest\n` diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 86e8208f..82d25620 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -12,6 +12,7 @@ import { resolve } from "node:path"; import { platform, arch, release, homedir, hostname } from "node:os"; import { createHmac } from "node:crypto"; import { trackInstallEvent } from "./install-telemetry.mjs"; +import { diagnoseShadow } from "./install-diagnosis.mjs"; // Skip when running in development context (e.g. `bun install` in the source repo). // INIT_CWD is set by npm/bun to the directory where install was invoked; it differs @@ -29,6 +30,30 @@ if (!existsSync(serverJsPath)) { process.exit(1); } +// Detect when an older `failproofai` is shadowing this fresh install on PATH — +// classic case is a leftover `bun link` from a prior dev session, or a +// `bun install -g` whose ~/.bun/bin sorts ahead of npm's prefix. Without this +// warning the user only finds out later via a confusing runtime error from +// scripts/launch.ts pointing at the *old* install's missing build output. +try { + let selfVersion = null; + try { + selfVersion = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")).version ?? null; + } catch {} + const diag = diagnoseShadow({ selfPackageRoot: process.cwd(), selfVersion }); + if (diag.shadowed) { + console.warn( + `\n[failproofai] Warning: another failproofai install is earlier on your PATH.\n` + + ` Just installed: ${diag.selfPackageRoot}` + (diag.selfVersion ? ` (v${diag.selfVersion})` : "") + `\n` + + ` PATH resolves : ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n\n` + + ` Your shell will run the older copy. Remove the shadow with:\n` + + ` ${diag.recommendation}\n` + ); + } +} catch { + // Diagnosis is best-effort — never fail the install over a warning. +} + const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__"; const NAMESPACE = "failproofai-telemetry-v1"; From 669e818e9ae9e963e43dc07987df63f45d8d5a19 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 17:45:11 -0700 Subject: [PATCH 2/2] fix: address CodeRabbit review on shadow-install diagnosis (1) buildRecommendation now keys on `pathFirstBin` rather than the realpath'd package root. In bun-link cases, realpath unwraps the link into the dev-tree path (not under ~/.bun/), so the previous root-based isBun check mis-classified bun-link shadows as npm and produced a useless `npm rm -g` recommendation. Trusting the un-resolved binary path catches both bun-link and bun-install shadows correctly. (2) Shadow detection now also fires when `selfPackageRoot` matches `pathFirstPath` but a different failproofai exists at the npm or bun global. This is the runtime stale-binary case the diagnosis was designed for: the user is running the shadow, so the two roots agree, and the previous "self != path-first" check returned false there. The launch.ts caller now picks the alternate install (npm or bun global, whichever differs from path-first) for the "Newer copy:" line so we never point the user back at the same binary they're already running. Adds an explicit recommendation assertion to the existing bun-link test plus a new test covering the runtime stale-binary scenario. Co-Authored-By: Claude Opus 4.7 --- __tests__/scripts/install-diagnosis.test.ts | 45 +++++++++++++++++++++ scripts/install-diagnosis.mjs | 37 ++++++++++------- scripts/launch.ts | 15 ++++++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/__tests__/scripts/install-diagnosis.test.ts b/__tests__/scripts/install-diagnosis.test.ts index 43179823..034feeed 100644 --- a/__tests__/scripts/install-diagnosis.test.ts +++ b/__tests__/scripts/install-diagnosis.test.ts @@ -126,6 +126,51 @@ describe("diagnoseShadow", () => { expect(diag.npmGlobalVersion).toBe("0.0.10-beta.0"); expect(diag.shadowDescription).toContain("0.0.9-beta.3"); expect(diag.shadowDescription).toContain("0.0.10-beta.0"); + // The dev-tree package root is NOT under ~/.bun, but the bin we invoke is — + // recommendation must use that signal to produce the bun-style cleanup. + expect(diag.recommendation).toContain("~/.bun/bin/failproofai"); + }); + + it("flags shadow at runtime when the running binary IS PATH-first but a different npm global exists", async () => { + // Scenario: user runs the stale bun-linked binary. selfPackageRoot === + // pathFirstPath. There's also a (newer) npm global install they wanted. + const { existsSync, readFileSync, realpathSync } = await import("node:fs"); + const { spawnSync } = await import("node:child_process"); + + vi.mocked(realpathSync).mockImplementation((p: any) => { + if (p === `${FAKE_HOME}/.bun/bin/failproofai`) return BUN_BIN_REAL_TARGET; + return p; + }); + vi.mocked(existsSync).mockImplementation(existsForPaths(new Set([ + `${BUN_LINKED_PKG}/package.json`, + BUN_LINKED_PKG, + `${NPM_GLOBAL_PKG}/package.json`, + NPM_GLOBAL_PKG, + BUN_BIN_REAL_TARGET, + ]))); + vi.mocked(readFileSync).mockImplementation(readForPackageJsons(new Map([ + [`${BUN_LINKED_PKG}/package.json`, { name: "failproofai", version: "0.0.9-beta.3" }], + [`${NPM_GLOBAL_PKG}/package.json`, { name: "failproofai", version: "0.0.10-beta.0" }], + ]))); + vi.mocked(spawnSync).mockImplementation(((cmd: any, args: any) => { + if (cmd === "sh" && args[1].startsWith("command -v")) { + return { status: 0, stdout: `${FAKE_HOME}/.bun/bin/failproofai\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + if (cmd === "npm") { + return { status: 0, stdout: `${NPM_GLOBAL_ROOT}\n`, stderr: "", signal: null, output: [] as any, pid: 0 }; + } + return { status: 1, stdout: "", stderr: "", signal: null, output: [] as any, pid: 0 }; + }) as any); + + const { diagnoseShadow } = await import("../../scripts/install-diagnosis.mjs"); + // Caller's selfPackageRoot equals pathFirstPath — the launch.ts case where + // the running binary IS the shadow. + const diag = diagnoseShadow({ selfPackageRoot: BUN_LINKED_PKG, selfVersion: "0.0.9-beta.3" }); + + expect(diag.shadowed).toBe(true); + expect(diag.pathFirstPath).toBe(BUN_LINKED_PKG); + expect(diag.npmGlobalPath).toBe(NPM_GLOBAL_PKG); + expect(diag.recommendation).toContain("~/.bun/bin/failproofai"); }); it("recommends `rm ~/.bun/bin/...` when the shadow lives under ~/.bun", async () => { diff --git a/scripts/install-diagnosis.mjs b/scripts/install-diagnosis.mjs index 5b26d671..4eca8609 100644 --- a/scripts/install-diagnosis.mjs +++ b/scripts/install-diagnosis.mjs @@ -102,14 +102,17 @@ function locateBunGlobal() { /** * Build a copy-pasteable cleanup command for the offending install. - * Bun-link symlinks live under ~/.bun — we recommend `bun unlink` (cleaner than `rm`) - * plus removing the bin symlink. For npm, plain `npm rm -g` is enough. + * + * The signal we trust is `pathFirstBin` — the un-resolved binary location PATH + * pointed to. For bun-link shadows the realpath'd package root is the dev tree + * (not under ~/.bun/), so checking the package root would mis-classify those + * shadows as npm and recommend the wrong cleanup. */ -function buildRecommendation(pathFirstPackageRoot) { - if (!pathFirstPackageRoot) return null; - const isBun = - pathFirstPackageRoot.includes(`${homedir()}/.bun/`) || - pathFirstPackageRoot.includes("/.bun/install/global/"); +function buildRecommendation(pathFirstBin) { + if (!pathFirstBin) return null; + const bunBinPrefix = resolve(homedir(), ".bun", "bin") + "/"; + const bunGlobalPrefix = resolve(homedir(), ".bun", "install", "global") + "/"; + const isBun = pathFirstBin.startsWith(bunBinPrefix) || pathFirstBin.startsWith(bunGlobalPrefix); if (isBun) { return `rm -f ~/.bun/bin/${PKG_NAME} && rm -rf ~/.bun/install/global/node_modules/${PKG_NAME}`; } @@ -143,16 +146,22 @@ export function diagnoseShadow(self) { const bunGlobalPath = locateBunGlobal(); const bunGlobalVersion = readPackageVersion(bunGlobalPath); - // "Shadow" = PATH resolves to a different package root than `self`, OR (when - // selfVersion is known) PATH-resolved version disagrees with self's version. - // We only flag it when we actually found a PATH copy AND a self copy to compare. + // "Shadow" covers two scenarios: + // 1. Postinstall case — `selfPackageRoot` is the just-installed copy and + // PATH resolves elsewhere. Flag when the two roots differ. + // 2. Runtime case — the running binary IS the shadow (so selfPackageRoot + // === pathFirstPackageRoot), but a *different* failproofai install + // exists at the npm or bun global. Flag when one of those differs from + // pathFirstPackageRoot. let shadowed = false; - if (selfPackageRoot && pathFirstPackageRoot) { - if (pathFirstPackageRoot !== selfPackageRoot) shadowed = true; - else if (selfVersion && pathFirstVersion && pathFirstVersion !== selfVersion) shadowed = true; + if (selfPackageRoot && pathFirstPackageRoot && pathFirstPackageRoot !== selfPackageRoot) { + shadowed = true; + } else if (pathFirstPackageRoot) { + if (npmGlobalPath && npmGlobalPath !== pathFirstPackageRoot) shadowed = true; + else if (bunGlobalPath && bunGlobalPath !== pathFirstPackageRoot) shadowed = true; } - const recommendation = shadowed ? buildRecommendation(pathFirstPackageRoot) : null; + const recommendation = shadowed ? buildRecommendation(pathFirstBin) : null; // A short human-readable summary used by callers that want a one-liner. let shadowDescription = null; diff --git a/scripts/launch.ts b/scripts/launch.ts index aa2b0a85..dd7fae1f 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -58,8 +58,19 @@ export function launch(mode: "dev" | "start"): void { try { const diag = diagnoseShadow({ selfPackageRoot: packageRoot, selfVersion: version }); if (diag.shadowed) { - const newer = diag.npmGlobalPath ?? diag.bunGlobalPath ?? "(unknown)"; - const newerVer = diag.npmGlobalVersion ?? diag.bunGlobalVersion ?? "?"; + // Pick whichever alternate install exists at npm/bun globals AND + // differs from PATH-first. In the runtime stale-binary scenario the + // running install IS the PATH-first one, so we'd otherwise point the + // user back at themselves. + const alt = + (diag.npmGlobalPath && diag.npmGlobalPath !== diag.pathFirstPath + ? { path: diag.npmGlobalPath, version: diag.npmGlobalVersion } + : null) + ?? (diag.bunGlobalPath && diag.bunGlobalPath !== diag.pathFirstPath + ? { path: diag.bunGlobalPath, version: diag.bunGlobalVersion } + : null); + const newer = alt?.path ?? "(unknown)"; + const newerVer = alt?.version ?? "?"; shadowMessage = `\nError: failproofai on your PATH is a stale install that no longer has its build output.\n` + ` Running: ${diag.pathFirstPath}` + (diag.pathFirstVersion ? ` (v${diag.pathFirstVersion})` : "") + `\n` +