Compare commits

..

2 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
1e0bc6fb21 Merge branch 'release/v3.8.50' into fix/release-v3.8.50-pin-next-exact 2026-08-14 12:57:52 -03:00
Xiangzhe
e5c9722b07 fix(deps): pin next to an exact version so a fresh upstream release cannot break installs
The published package ships a PREBUILT .next directory, and next start reads
build manifests whose shape changes between minors — so the runtime version
must be the one that produced the build. With "next": "^16.2.11", every
`npm i -g omniroute` resolved whatever Next was latest at INSTALL time.

Next 16.3.1 was published 2026-08-13T22:45Z and added `validationLevel` to
its server config schema (0 occurrences in 16.2.12, 74 in 16.3.1). Any install
after that timestamp boots a 16.2.12-built .next on the 16.3.1 runtime and
crashes immediately:

  TypeError: Cannot read properties of undefined (reading 'validationLevel')

Reproduced on the 192.168.0.17 VPS: a fresh global install of the 3.8.50
tarball crashed in a restart loop; the previous install (next 16.3.0) is
healthy, and nothing in this repo changed between them. Published 3.8.49
carries the same range, so new user installs are affected too.

react/react-dom were already pinned exactly for this reason; this extends the
invariant to next, syncs the lockfile range, and adds
tests/unit/next-version-pinned.test.ts as the regression guard (asserts the
build-coupled deps are exact and that package.json matches the lockfile
version the build actually uses).
2026-08-14 01:10:34 -03:00
6 changed files with 83 additions and 61 deletions

View File

@@ -1 +0,0 @@
- fix(cli): drop the orphaned `resolveOpencodeConfigDir` re-export from `cliRuntime` — it lost its last consumer in #10246 and diverged from the canonical resolver by one directory level (#9985)

2
package-lock.json generated
View File

@@ -56,7 +56,7 @@
"material-symbols": "^0.45.2",
"mermaid": "^11.15.0",
"monaco-editor": "^0.56.0",
"next": "^16.2.11",
"next": "16.2.12",
"next-intl": "^4.12.0",
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",

View File

@@ -298,7 +298,7 @@
"material-symbols": "^0.45.2",
"mermaid": "^11.15.0",
"monaco-editor": "^0.56.0",
"next": "^16.2.11",
"next": "16.2.12",
"next-intl": "^4.12.0",
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",

View File

@@ -9,7 +9,10 @@ import { withSettingsFallback } from "./cliInstallFallback";
import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild";
import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath";
import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath";
import { resolveOpencodeConfigPath as resolveOpenCodeConfigPath } from "./opencodeConfigPath";
import {
resolveOpencodeConfigDir as resolveOpenCodeConfigDir,
resolveOpencodeConfigPath as resolveOpenCodeConfigPath,
} from "./opencodeConfigPath";
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
@@ -970,6 +973,19 @@ export const getCliConfigHome = () => {
return normalized;
};
export const resolveOpencodeConfigDir = (
_platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
) => {
// #3330: OpenCode reads its config from XDG `~/.config/opencode/` on ALL
// platforms — including Windows, where it uses `%USERPROFILE%\.config`, NOT
// `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode
// never looks, so dashboard-saved config silently had no effect. `_platform`
// is kept in the signature for call-site/test compatibility.
return path.dirname(resolveOpenCodeConfigDir(env, homeDir));
};
export const resolveOpencodeConfigPath = (
_platform = process.platform,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -0,0 +1,64 @@
/**
* next-version-pinned.test.ts — `next` must be pinned to an exact version.
*
* The published package ships a PREBUILT `.next` directory. That build output is
* tightly coupled to the exact Next.js runtime that produced it: `next start`
* reads build manifests whose shape changes between minors. With a caret range,
* `npm i -g omniroute` resolves whatever Next is latest at INSTALL time, so a
* fresh upstream release silently breaks every new install even though nothing
* in this repo changed.
*
* That is not hypothetical: Next 16.3.1 (published 2026-08-13T22:45Z) added
* `validationLevel` to its server config schema, and a `.next` built by 16.2.12
* crashes at boot with "Cannot read properties of undefined (reading
* 'validationLevel')" — the symbol has 0 occurrences in 16.2.12 and 74 in
* 16.3.1. Range `^16.2.11` picked it up on the VPS install (2026-08-14).
*
* `react`/`react-dom` are already pinned exactly for the same reason; this test
* extends that invariant to `next` and keeps package.json in sync with the
* lockfile version the build actually uses.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const ROOT = new URL("../../", import.meta.url);
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
/** Deps whose published artifact is coupled to the exact installed runtime. */
const MUST_BE_EXACT = ["next", "react", "react-dom"];
async function readJson(relative: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fileURLToPath(new URL(relative, ROOT)), "utf8"));
}
test("build-coupled dependencies are pinned to exact versions", async () => {
const pkg = await readJson("package.json");
const deps = (pkg.dependencies ?? {}) as Record<string, string>;
const ranged = MUST_BE_EXACT.filter((name) => deps[name] && !EXACT_VERSION.test(deps[name]));
assert.deepEqual(
ranged,
[],
"these ship a prebuilt artifact and must be pinned exactly (a range lets a fresh " +
`upstream release break new installs): ${ranged.map((n) => `${n}@${deps[n]}`).join(", ")}`
);
});
test("pinned next version matches the lockfile version the build uses", async () => {
const pkg = await readJson("package.json");
const lock = await readJson("package-lock.json");
const declared = ((pkg.dependencies ?? {}) as Record<string, string>).next;
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>;
const locked = packages["node_modules/next"]?.version;
assert.ok(locked, "next missing from package-lock.json");
assert.equal(
declared,
locked,
`package.json declares next@${declared} but the lockfile builds with next@${locked}`
);
});

View File

@@ -1,57 +0,0 @@
// Regression guard for the #10246 follow-up: `resolveOpencodeConfigDir` has exactly ONE
// implementation, in `src/shared/services/opencodeConfigPath.ts`.
//
// #10246 moved the canonical resolvers into `opencodeConfigPath.ts` and left a thin wrapper
// `resolveOpencodeConfigDir` behind in `cliRuntime.ts`. That wrapper lost its last consumer in
// the same commit and became a dead export — which is what pushed the `check:dead-code` ratchet
// to 410 (baseline 409) and made every PR on `release/v3.8.50` born red on that gate.
//
// Worse than the ratchet: the wrapper returned `path.dirname()` of the canonical value, i.e.
// `~/.config` instead of `~/.config/opencode`. Two same-named exports with DIFFERENT return
// values is a live foot-gun — a future caller importing from `cliRuntime` instead of
// `opencodeConfigPath` would silently write the OpenCode config one directory too high.
//
// This test pins both halves: the canonical resolver's contract, and the absence of the
// divergent re-export.
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { resolveOpencodeConfigDir } from "@/shared/services/opencodeConfigPath";
import * as cliRuntime from "@/shared/services/cliRuntime";
test("#10246 canonical resolveOpencodeConfigDir returns the XDG opencode directory", () => {
assert.equal(
resolveOpencodeConfigDir({ XDG_CONFIG_HOME: "/xdg" }, "/home/u"),
path.join("/xdg", "opencode")
);
// No XDG_CONFIG_HOME → `<home>/.config/opencode` on every platform (#3330: OpenCode reads
// XDG even on Windows, where it uses %USERPROFILE%\.config and never %APPDATA%).
assert.equal(
resolveOpencodeConfigDir({}, "/home/u"),
path.join("/home/u", ".config", "opencode")
);
// A blank/whitespace XDG_CONFIG_HOME must fall back, not produce a relative path.
assert.equal(
resolveOpencodeConfigDir({ XDG_CONFIG_HOME: " " }, "/home/u"),
path.join("/home/u", ".config", "opencode")
);
});
test("#10246 cliRuntime does NOT re-export a divergent resolveOpencodeConfigDir", () => {
assert.equal(
(cliRuntime as Record<string, unknown>).resolveOpencodeConfigDir,
undefined,
"cliRuntime must not re-export resolveOpencodeConfigDir — the wrapper returned the PARENT " +
"directory (path.dirname of the canonical value), so importing it by name would write the " +
"OpenCode config one level too high. Import it from opencodeConfigPath instead."
);
});
test("#10246 cliRuntime still exposes the config PATH helpers it owns", () => {
// The path helpers legitimately stay on cliRuntime (they have live consumers) — this guard
// must not be read as "cliRuntime should stop exporting OpenCode helpers entirely".
assert.equal(typeof cliRuntime.resolveOpencodeConfigPath, "function");
assert.equal(typeof cliRuntime.getOpenCodeConfigPath, "function");
});