diff --git a/changelog.d/fixes/16321-home-route-i18n-and-pack-artifact-bun.md b/changelog.d/fixes/16321-home-route-i18n-and-pack-artifact-bun.md new file mode 100644 index 000000000000..c5ce849f1c31 --- /dev/null +++ b/changelog.d/fixes/16321-home-route-i18n-and-pack-artifact-bun.md @@ -0,0 +1,2 @@ +- **fix(i18n):** mount the `/home` route's message provider so the home page and its sponsor banners stop rendering `MISSING_MESSAGE` in production builds ([#16321](https://github.com/diegosouzapw/OmniRoute/issues/16321)). +- **fix(build):** `check:pack-artifact` runs npm through the bundled `npm-cli.js` instead of trusting `npm_execpath`, so `bun run` no longer dumps the Bun ELF binary into the validator's error output ([#16321](https://github.com/diegosouzapw/OmniRoute/issues/16321)). diff --git a/scripts/build/resolveNpmEntry.ts b/scripts/build/resolveNpmEntry.ts index b19bcb1fb2f7..17506be43ccf 100644 --- a/scripts/build/resolveNpmEntry.ts +++ b/scripts/build/resolveNpmEntry.ts @@ -38,3 +38,43 @@ export function resolveBundledNpmEntry( } return null; } + +/** Executable + argv for an npm subcommand (no shell, no `.cmd` shim). */ +export interface NpmInvocation { + command: string; + args: string[]; +} + +export interface NpmInvocationDeps extends ResolveNpmEntryDeps { + platform?: NodeJS.Platform; + /** Pre-resolved `npm-cli.js` path; `null` forces the plain-command fallback. */ + npmEntry?: string | null; +} + +/** + * Build the executable + argv used to run an npm subcommand from a build script. + * + * Prefers `process.execPath ` — the reliable cross-platform + * form that never touches a `.cmd` shim (#8858). Crucially it does NOT trust + * `npm_execpath` as a JS entry point: non-npm runners set it to their own + * executable, so `bun run` leaves it pointing at the bare `bun` binary. Feeding + * that to `process.execPath` produced `node /path/to/bun …`, and Node dumped the + * raw ELF bytes into the caller's error output (#16321). + * + * Falls back to the plain `npm` (`npm.cmd` on Windows) command only when no + * bundled `npm-cli.js` can be located. + */ +export function resolveNpmInvocation( + args: readonly string[], + deps: NpmInvocationDeps = {} +): NpmInvocation { + const platform = deps.platform ?? process.platform; + const execPath = deps.execPath ?? process.execPath; + const npmEntry = + deps.npmEntry !== undefined ? deps.npmEntry : resolveBundledNpmEntry("npm-cli.js", deps); + + if (npmEntry) { + return { command: execPath, args: [npmEntry, ...args] }; + } + return { command: platform === "win32" ? "npm.cmd" : "npm", args: [...args] }; +} diff --git a/scripts/build/validate-pack-artifact.ts b/scripts/build/validate-pack-artifact.ts index d0d30fbbd6b7..f385015f0d17 100644 --- a/scripts/build/validate-pack-artifact.ts +++ b/scripts/build/validate-pack-artifact.ts @@ -4,11 +4,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { - makeGitAncestryProbe, - readBuildSha, - resolveBuildProvenance, -} from "./buildProvenance.ts"; +import { makeGitAncestryProbe, readBuildSha, resolveBuildProvenance } from "./buildProvenance.ts"; import { MCP_CLOSURE_SPOT_CHECK_PATH, @@ -16,6 +12,7 @@ import { findLeakedTestArtifactPaths, findMissingMcpClosurePaths, } from "./mcpPublishedFilesClosure.ts"; +import { resolveNpmInvocation } from "./resolveNpmEntry.ts"; import { PACK_ARTIFACT_ALLOWED_EXACT_PATHS, PACK_ARTIFACT_ALLOWED_PATH_PREFIXES, @@ -28,13 +25,13 @@ import { const __filename: string = fileURLToPath(import.meta.url); const __dirname: string = dirname(__filename); const ROOT: string = join(__dirname, "..", ".."); -const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm"; function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string { - const npmExecPath = process.env.npm_execpath; - const isBunRuntime = "Bun" in globalThis; - const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand; - const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args; + // Prefer running npm's own `npm-cli.js` through the current Node binary. + // Never treat `npm_execpath` as a JS entry unconditionally: `bun run` points + // it at the bare `bun` ELF binary, which Node then tries to execute as + // JavaScript and dumps into the error output (#16321). + const { command, args: commandArgs } = resolveNpmInvocation(args); if (stdio === "inherit") { execFileSync(command, commandArgs, { diff --git a/scripts/i18n/generate-route-namespaces.mjs b/scripts/i18n/generate-route-namespaces.mjs index b6856202e2e7..154506421aef 100644 --- a/scripts/i18n/generate-route-namespaces.mjs +++ b/scripts/i18n/generate-route-namespaces.mjs @@ -29,6 +29,7 @@ import { analyzeRouteNamespaces, REPO_ROOT } from "./lib/routeNamespacesAnalyzer const __dirname = dirname(fileURLToPath(import.meta.url)); const MAP_PATH = join(REPO_ROOT, "src", "i18n", "routeNamespaces.generated.json"); const SECTIONS_ROOT = join(REPO_ROOT, "src", "app", "(dashboard)", "dashboard"); +const HOME_ROUTE_ROOT = join(REPO_ROOT, "src", "app", "(dashboard)", "home"); const GENERATED_MARKER = "// GENERATED by scripts/i18n/generate-route-namespaces.mjs — do not edit by hand."; @@ -60,6 +61,37 @@ export default function ${pascalCase(sectionDirName)}SectionLayout({ children }: `; } +function homeRouteLayoutSource() { + return `${GENERATED_MARKER} +import { SectionI18nProvider } from "@/i18n/SectionI18nProvider"; +import sectionNamespaces from "@/i18n/routeNamespaces.generated.json"; + +/** + * Home route (\`/home\`) i18n provider — the "home" set covers the home page, + * the sponsor banners and the companion components under + * \`src/app/(dashboard)/dashboard/\` that it renders. Without this layout the + * route inherits the dashboard chrome provider, whose namespace set is missing + * "home" and every banner namespace, so next-intl renders MISSING_MESSAGE + * errors in production (#16321). + */ +export default function HomeRouteLayout({ children }: { children: React.ReactNode }) { + return {children}; +} +`; +} + +/** Ensure a provider layout exists; report a hand-edited file without one. */ +function ensureProviderLayout(layoutPath, source, label, skippedNames) { + if (existsSync(layoutPath)) { + const existing = readFileSync(layoutPath, "utf8"); + if (!existing.includes("SectionI18nProvider")) skippedNames.push(label); + return 0; + } + mkdirSync(dirname(layoutPath), { recursive: true }); + writeFileSync(layoutPath, source); + return 1; +} + const analysis = analyzeRouteNamespaces(); // Map file: the source of truth consumed at runtime. @@ -70,24 +102,30 @@ let created = 0; const skippedNames = []; for (const sectionDirName of Object.keys(analysis.sections)) { const layoutPath = join(SECTIONS_ROOT, sectionDirName, "layout.tsx"); - if (existsSync(layoutPath)) { - const existing = readFileSync(layoutPath, "utf8"); - if (!existing.includes("SectionI18nProvider")) { - skippedNames.push(sectionDirName); - } - continue; - } - mkdirSync(dirname(layoutPath), { recursive: true }); - writeFileSync(layoutPath, sectionLayoutSource(sectionDirName)); - created += 1; + created += ensureProviderLayout( + layoutPath, + sectionLayoutSource(sectionDirName), + sectionDirName, + skippedNames + ); } +// Home route layout. `/dashboard` redirects to `/home`, and `/home` renders the +// home page + sponsor banners, so it needs its own provider — without it the +// route falls back to chrome-only messages and renders MISSING_MESSAGE (#16321). +created += ensureProviderLayout( + join(HOME_ROUTE_ROOT, "layout.tsx"), + homeRouteLayoutSource(), + "home", + skippedNames +); + const sectionCount = Object.keys(analysis.sections).length; console.log("map written: src/i18n/routeNamespaces.generated.json"); console.log( `root=${analysis.root.length} chrome=${analysis.chrome.length} home=${analysis.home.length} sections=${sectionCount}` ); -console.log(`section layouts created: ${created}, pre-existing (kept): ${sectionCount - created}`); +console.log(`provider layouts created: ${created} (sections: ${sectionCount} + home route)`); if (skippedNames.length > 0) { console.log( `WARNING: pre-existing layouts without SectionI18nProvider: ${skippedNames.join(", ")}` diff --git a/scripts/i18n/lib/routeNamespacesAnalyzer.mjs b/scripts/i18n/lib/routeNamespacesAnalyzer.mjs index 7e92a35695b0..f6e1634c9f3d 100644 --- a/scripts/i18n/lib/routeNamespacesAnalyzer.mjs +++ b/scripts/i18n/lib/routeNamespacesAnalyzer.mjs @@ -9,8 +9,9 @@ * client components). * - `chrome` — namespaces needed by the shared dashboard shell rendered by * `src/app/(dashboard)/layout.tsx` (Header, Sidebar, …). - * - `home` — the dashboard home route (`/dashboard` page.tsx + the - * component files that live directly under `dashboard/`). + * - `home` — the dashboard home route: the `/dashboard` redirect page plus + * the component files that live directly under `dashboard/` and + * the `/home` route directory that renders them. * - `sections`— one entry per top-level section directory under * `src/app/(dashboard)/dashboard/` that (recursively) contains * a page.tsx. @@ -42,6 +43,10 @@ const SRC_ROOT = join(REPO_ROOT, "src"); const APP_ROOT = join(SRC_ROOT, "app"); const DASHBOARD_ROOT = join(APP_ROOT, "(dashboard)"); const SECTIONS_ROOT = join(DASHBOARD_ROOT, "dashboard"); +// The real post-redirect home route (`/dashboard` → `/home`). It renders the +// companion components that live directly under `dashboard/` and therefore +// shares the `home` namespace set (#16321). +const HOME_ROUTE_ROOT = join(DASHBOARD_ROOT, "home"); const SKIP_DIRS = new Set(["node_modules", ".next", ".claude", ".git", "_tasks", "__pycache__"]); @@ -228,12 +233,15 @@ export function analyzeRouteNamespaces() { // Dashboard chrome: everything reachable from the (dashboard) root layout. const chrome = closureNamespaces([join(DASHBOARD_ROOT, "layout.tsx")]); - // Dashboard home: files directly under dashboard/ (page + companions). + // Dashboard home: files directly under dashboard/ (page + companions) AND the + // real /home route directory that renders them — `/dashboard` redirects to + // `/home`, and `/home` imports the same companion components (#16321). const homeFiles = readdirSync(SECTIONS_ROOT) .filter((name) => /\.(tsx|ts)$/.test(name)) .map((name) => join(SECTIONS_ROOT, name)); + const homeRouteFiles = walkFiles(HOME_ROUTE_ROOT); const home = new Set([...chrome]); - for (const ns of closureNamespaces(homeFiles)) home.add(ns); + for (const ns of closureNamespaces([...homeFiles, ...homeRouteFiles])) home.add(ns); // Sections: one entry per top-level dir under dashboard/ with a page. const sections = {}; diff --git a/src/app/(dashboard)/home/layout.tsx b/src/app/(dashboard)/home/layout.tsx new file mode 100644 index 000000000000..ce0afc79a48f --- /dev/null +++ b/src/app/(dashboard)/home/layout.tsx @@ -0,0 +1,15 @@ +// GENERATED by scripts/i18n/generate-route-namespaces.mjs — do not edit by hand. +import { SectionI18nProvider } from "@/i18n/SectionI18nProvider"; +import sectionNamespaces from "@/i18n/routeNamespaces.generated.json"; + +/** + * Home route (`/home`) i18n provider — the "home" set covers the home page, + * the sponsor banners and the companion components under + * `src/app/(dashboard)/dashboard/` that it renders. Without this layout the + * route inherits the dashboard chrome provider, whose namespace set is missing + * "home" and every banner namespace, so next-intl renders MISSING_MESSAGE + * errors in production (#16321). + */ +export default function HomeRouteLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/i18n/routeNamespaces.generated.json b/src/i18n/routeNamespaces.generated.json index cb49a00648d4..a9c296bb9e27 100644 --- a/src/i18n/routeNamespaces.generated.json +++ b/src/i18n/routeNamespaces.generated.json @@ -90,6 +90,7 @@ "stats", "traeAuthModal", "trafficInspector", + "usage", "vscodeCopilotBanner" ], "sections": { diff --git a/tests/unit/build/resolve-npm-invocation.test.ts b/tests/unit/build/resolve-npm-invocation.test.ts new file mode 100644 index 000000000000..333e130fadbe --- /dev/null +++ b/tests/unit/build/resolve-npm-invocation.test.ts @@ -0,0 +1,79 @@ +/** + * #16321 — `check:pack-artifact` failed under `bun run` with + * `❌ Pack artifact validation failed: /home/temp/.bun/bin/bun:1` followed by + * raw ELF bytes. The validator used `process.env.npm_execpath` as a JS entry + * point without checking what it pointed at; Bun sets it to its own binary, so + * the validator ran `node /path/to/bun …` and Node dumped the executable into + * the error output. `resolveNpmInvocation` must never do that. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveNpmInvocation } from "../../../scripts/build/resolveNpmEntry.ts"; + +const POSIX_PREFIX = "/opt/hostedtoolcache/node/24.18.0/x64"; +const POSIX_NODE = `${POSIX_PREFIX}/bin/node`; +const POSIX_NPM_CLI = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npm-cli.js`; + +const BUN_BINARY = "/home/temp/.bun/bin/bun"; + +test("npm run: runs the resolved npm-cli.js through the current node binary", () => { + const invocation = resolveNpmInvocation(["run", "build:cli"], { + execPath: POSIX_NODE, + npmExecPath: POSIX_NPM_CLI, + exists: (p) => p === POSIX_NPM_CLI, + }); + + assert.deepEqual(invocation, { + command: POSIX_NODE, + args: [POSIX_NPM_CLI, "run", "build:cli"], + }); +}); + +test("bun run: npm_execpath pointing at the bun binary is never used as a JS entry", () => { + const invocation = resolveNpmInvocation(["pack", "--dry-run", "--json"], { + execPath: POSIX_NODE, + // Bun sets npm_execpath to its own ELF binary. + npmExecPath: BUN_BINARY, + exists: (p) => p === POSIX_NPM_CLI, + }); + + assert.equal(invocation.command, POSIX_NODE); + assert.deepEqual(invocation.args, [POSIX_NPM_CLI, "pack", "--dry-run", "--json"]); + assert.ok( + !invocation.args.includes(BUN_BINARY), + "the bun binary must never be passed to node as a script (#16321)" + ); +}); + +test("falls back to the plain npm command when no bundled npm-cli.js exists", () => { + const invocation = resolveNpmInvocation(["pack"], { + execPath: POSIX_NODE, + npmExecPath: BUN_BINARY, + exists: () => false, + platform: "linux", + }); + + assert.deepEqual(invocation, { command: "npm", args: ["pack"] }); +}); + +test("Windows fallback uses npm.cmd (never the bun binary)", () => { + const invocation = resolveNpmInvocation(["pack"], { + execPath: "C:\\nodejs\\node.exe", + npmExecPath: "C:\\bun\\bun.exe", + exists: () => false, + platform: "win32", + }); + + assert.deepEqual(invocation, { command: "npm.cmd", args: ["pack"] }); +}); + +test("explicit null npmEntry forces the fallback command", () => { + const invocation = resolveNpmInvocation(["run", "build:cli"], { + execPath: POSIX_NODE, + npmEntry: null, + platform: "linux", + }); + + assert.deepEqual(invocation, { command: "npm", args: ["run", "build:cli"] }); +}); diff --git a/tests/unit/i18n-route-namespaces.test.ts b/tests/unit/i18n-route-namespaces.test.ts index 78ec22e753b2..e97d1aa330d4 100644 --- a/tests/unit/i18n-route-namespaces.test.ts +++ b/tests/unit/i18n-route-namespaces.test.ts @@ -29,6 +29,7 @@ import { pickMessages } from "../../src/i18n/pickMessages"; const __dirname = dirname(fileURLToPath(import.meta.url)); const MAP_PATH = join(REPO_ROOT, "src", "i18n", "routeNamespaces.generated.json"); const SECTIONS_ROOT = join(REPO_ROOT, "src", "app", "(dashboard)", "dashboard"); +const HOME_LAYOUT_PATH = join(REPO_ROOT, "src", "app", "(dashboard)", "home", "layout.tsx"); test("generated route-namespace map is fresh (run npm run gen:i18n-routes)", () => { const committed = JSON.parse(readFileSync(MAP_PATH, "utf8")); @@ -61,6 +62,32 @@ test("every dashboard section has a SectionI18nProvider layout", () => { ); }); +test("the /home route mounts a SectionI18nProvider layout (#16321)", () => { + assert.ok( + existsSync(HOME_LAYOUT_PATH), + "src/app/(dashboard)/home/layout.tsx is missing — /home then inherits the chrome-only " + + "provider and its home/banner namespaces render MISSING_MESSAGE in production" + ); + const source = readFileSync(HOME_LAYOUT_PATH, "utf8"); + assert.ok(source.includes("SectionI18nProvider"), "home layout must mount SectionI18nProvider"); + assert.ok( + source.includes("sectionNamespaces.home"), + "home layout must serve the generated `home` namespace set" + ); +}); + +test("/home's own namespaces stay out of chrome — that is why its layout is required (#16321)", () => { + const { chrome, home } = JSON.parse(readFileSync(MAP_PATH, "utf8")); + for (const ns of ["home", "cheaperInferenceSponsorBanner", "vscodeCopilotBanner"]) { + assert.ok(home.includes(ns), `home namespace set must include "${ns}"`); + assert.ok( + !chrome.includes(ns), + `"${ns}" must not be part of chrome — the dashboard root provider only serves chrome, ` + + `so /home relies on its own layout to receive it` + ); + } +}); + test("every section set includes the chrome namespaces", () => { const { chrome, sections, home } = JSON.parse(readFileSync(MAP_PATH, "utf8")); const chromeSet = new Set(chrome);