Skip to content
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
@@ -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)).
40 changes: 40 additions & 0 deletions scripts/build/resolveNpmEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <npm-cli.js> <args>` — 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] };
}
17 changes: 7 additions & 10 deletions scripts/build/validate-pack-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@ 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,
computeMcpClosure,
findLeakedTestArtifactPaths,
findMissingMcpClosurePaths,
} from "./mcpPublishedFilesClosure.ts";
import { resolveNpmInvocation } from "./resolveNpmEntry.ts";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
Expand All @@ -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, {
Expand Down
60 changes: 49 additions & 11 deletions scripts/i18n/generate-route-namespaces.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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 <SectionI18nProvider namespaces={sectionNamespaces.home}>{children}</SectionI18nProvider>;
}
`;
}

/** 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.
Expand All @@ -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(", ")}`
Expand Down
16 changes: 12 additions & 4 deletions scripts/i18n/lib/routeNamespacesAnalyzer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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__"]);

Expand Down Expand Up @@ -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 = {};
Expand Down
15 changes: 15 additions & 0 deletions src/app/(dashboard)/home/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 <SectionI18nProvider namespaces={sectionNamespaces.home}>{children}</SectionI18nProvider>;
}
1 change: 1 addition & 0 deletions src/i18n/routeNamespaces.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"stats",
"traeAuthModal",
"trafficInspector",
"usage",
"vscodeCopilotBanner"
],
"sections": {
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/build/resolve-npm-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -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"] });
});
27 changes: 27 additions & 0 deletions tests/unit/i18n-route-namespaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
Loading