fix(build): resolve npm-cli.js on POSIX layouts in the shim-free prepublish resolver (#9553)

* fix(build): resolve npm-cli.js on POSIX layouts in the shim-free prepublish resolver

The #8858 resolver only tried <dir(node)>/node_modules/npm/bin — the
Windows layout. On POSIX (GitHub hosted runners, nvm, system installs)
npm lives at <prefix>/lib/node_modules/npm while node is <prefix>/bin/
node, so resolveBundledNpmEntry returned null and npm run build:cli
died installing @omniroute/opencode-plugin deps on every fresh checkout
('npm-cli.js not found next to the running Node binary') — redding Fast
Production Build and dast-smoke for the whole PR queue.

Extract the resolver to scripts/build/resolveNpmEntry.ts with injectable
seams and try, in order: npm_execpath (exported by npm run itself), the
Windows beside-the-binary layout, the POSIX <prefix>/lib layout.

TDD: tests/unit/build/resolve-npm-entry.test.ts — the POSIX-layout and
npm_execpath cases plus a live regression guard fail against the old
single-candidate logic (2/5) and pass with the fix (5/5).

* docs(env): register the 7 env vars orphaned by the 08-05 merge batch

The Docs Gates env/docs contract went red on the release tip: #9260
added OMNIROUTE_INTERNAL_SERVICE_TOKEN(_FILE) and #9324 added
OPENROUTER_PROVIDER_STATS_ENABLED/_TTL_MS without .env.example entries,
and the #9286 Redis sidecar vars (REDIS_BIND_HOST, REDIS_PORT,
OMNIROUTE_REDIS_BIND_HOST) never reached ENVIRONMENT.md. Inherited
base-red on every open PR. Defaults and descriptions taken from the
consuming source files.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-06 01:13:42 -03:00
committed by GitHub
parent bca61af8ae
commit e12d2d546b
6 changed files with 137 additions and 5 deletions

View File

@@ -1531,6 +1531,14 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
# ON by default; set false to skip the background fetch entirely (#9324).
# Used by: src/lib/catalog/openrouterProviderStats.ts
# OPENROUTER_PROVIDER_STATS_ENABLED=true
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
# Default: 86400000 (24 hours)
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
@@ -2162,6 +2170,15 @@ INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Shared secret for identity-preserving internal REST hops (#9260): when an
# OmniRoute component calls another local OmniRoute route, this token (sent as
# x-omniroute-internal-service-token) marks the request as internal so the
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
# Used by: src/lib/api/internalServiceAuth.ts
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# File-based variant (secret-file pattern; wins only when the inline var is
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)

View File

@@ -0,0 +1 @@
- **fix(build):** `npm run build:cli` (prepublish) no longer fails on POSIX with "npm-cli.js not found next to the running Node binary". The #8858 shim-free npm resolver only knew the Windows layout (`<dir(node.exe)>\node_modules\npm`); on GitHub hosted runners, nvm and system installs npm lives at `<prefix>/lib/node_modules/npm` while node is `<prefix>/bin/node`, so every fresh CI checkout died installing `@omniroute/opencode-plugin` deps (Fast Production Build + dast-smoke red on all PRs). The resolver, extracted to `scripts/build/resolveNpmEntry.ts`, now tries `npm_execpath` (exported by `npm run` itself) first, then the Windows layout, then the POSIX layout — covered by `tests/unit/build/resolve-npm-entry.test.ts` including a live POSIX regression guard.

View File

@@ -1150,6 +1150,13 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |

View File

@@ -27,6 +27,7 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
@@ -64,11 +65,6 @@ function resolveLocalBinEntry(packageName: string, binName: string): string | nu
}
}
function resolveBundledNpmEntry(name: "npm-cli.js" | "npx-cli.js"): string | null {
const candidate = join(dirname(process.execPath), "node_modules", "npm", "bin", name);
return existsSync(candidate) ? candidate : null;
}
/**
* Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the
* tool lives in the local dependency tree; when it is not installed there the call

View File

@@ -0,0 +1,40 @@
import { existsSync } from "fs";
import { dirname, join } from "path";
/** Injectable seams for {@link resolveBundledNpmEntry} (all default to the real ones). */
export interface ResolveNpmEntryDeps {
execPath?: string;
/** `process.env.npm_execpath` — set by npm itself when running under `npm run`. */
npmExecPath?: string;
exists?: (p: string) => boolean;
}
/**
* Locate `npm-cli.js` / `npx-cli.js` so build steps can run npm/npx through
* `process.execPath` directly and never touch a `.cmd` shim (#8858), covering
* BOTH install layouts:
* - Windows: `<dir(node.exe)>\node_modules\npm\bin\<name>` (npm beside the binary)
* - POSIX: `<dir(node)>/../lib/node_modules/npm/bin/<name>` (node under `<prefix>/bin`,
* the shape of GitHub hosted runners, nvm and system installs)
* When the script itself runs under `npm run`, npm exports `npm_execpath` pointing at
* its own npm-cli.js — the most reliable source, tried first (npx-cli.js is its sibling).
*/
export function resolveBundledNpmEntry(
name: "npm-cli.js" | "npx-cli.js",
deps: ResolveNpmEntryDeps = {}
): string | null {
const execPath = deps.execPath ?? process.execPath;
const exists = deps.exists ?? existsSync;
const npmExecPath = deps.npmExecPath ?? process.env.npm_execpath;
const binDir = dirname(execPath);
const candidates: string[] = [];
if (npmExecPath) candidates.push(join(dirname(npmExecPath), name));
candidates.push(join(binDir, "node_modules", "npm", "bin", name));
candidates.push(join(binDir, "..", "lib", "node_modules", "npm", "bin", name));
for (const candidate of candidates) {
if (exists(candidate)) return candidate;
}
return null;
}

View File

@@ -0,0 +1,71 @@
/**
* #8858 follow-up — `resolveBundledNpmEntry` only knew the WINDOWS npm layout
* (`<dir(node.exe)>\node_modules\npm\bin\...`). On POSIX installs (GitHub
* hosted runners, nvm, system node) npm lives at
* `<prefix>/lib/node_modules/npm/bin/...` while node is `<prefix>/bin/node`,
* so the resolver returned null and `npm run build:cli` (Fast Production
* Build, dast-smoke) failed on every fresh CI checkout with
* "npm-cli.js not found next to the running Node binary".
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveBundledNpmEntry } 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 WIN_STYLE_DIR = "/fake/nodejs"; // Windows layout shape (npm beside the binary)
const WIN_STYLE_NODE = `${WIN_STYLE_DIR}/node.exe`;
const WIN_STYLE_NPM_CLI = `${WIN_STYLE_DIR}/node_modules/npm/bin/npm-cli.js`;
test("POSIX layout: finds npm-cli.js under <prefix>/lib/node_modules (hosted runner shape)", () => {
const resolved = resolveBundledNpmEntry("npm-cli.js", {
execPath: POSIX_NODE,
npmExecPath: undefined,
exists: (p) => p === POSIX_NPM_CLI,
});
assert.equal(resolved, POSIX_NPM_CLI);
});
test("Windows layout: still finds npm-cli.js beside the node binary", () => {
const resolved = resolveBundledNpmEntry("npm-cli.js", {
execPath: WIN_STYLE_NODE,
npmExecPath: undefined,
exists: (p) => p === WIN_STYLE_NPM_CLI,
});
assert.equal(resolved, WIN_STYLE_NPM_CLI);
});
test("npm_execpath (set by `npm run`) wins and resolves npx-cli.js as its sibling", () => {
const npmExecPath = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npm-cli.js`;
const npxSibling = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npx-cli.js`;
const resolved = resolveBundledNpmEntry("npx-cli.js", {
execPath: POSIX_NODE,
npmExecPath,
exists: (p) => p === npxSibling,
});
assert.equal(resolved, npxSibling);
});
test("returns null when no layout matches", () => {
const resolved = resolveBundledNpmEntry("npm-cli.js", {
execPath: POSIX_NODE,
npmExecPath: undefined,
exists: () => false,
});
assert.equal(resolved, null);
});
test("live environment: the real node install can resolve npm-cli.js (POSIX regression guard)", (t) => {
if (process.platform === "win32") {
t.skip("POSIX-only live check");
return;
}
const resolved = resolveBundledNpmEntry("npm-cli.js");
assert.ok(
resolved,
`real install must resolve npm-cli.js (execPath=${process.execPath}) — the #8858 resolver returned null on POSIX`
);
});