diff --git a/CHANGELOG.md b/CHANGELOG.md index 305eff7dc2c..6bc240bb7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(ws): start the LiveWS sidecar with `cwd` at the package root (global/systemd installs)** — the standalone LiveWS launcher (`scripts/start-ws-server.mjs`) re-spawns itself with `node --import tsx ` but did not set `cwd`. When the WebSocket sidecar was launched from outside the package directory — a global npm/homebrew install, or a `systemd`/`launchd` unit started from `$HOME` — Node could not resolve the `tsx` package (`ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`), and even from the package directory `tsx` could not resolve the tsconfig `@/*` path aliases (e.g. `@/types/databaseSettings`), so the sidecar never booted. The spawn now pins `cwd` to the package root (the directory above `scripts/`, where `package.json` + `tsconfig.json` live), which resolves both `tsx` discovery and the `@/*` aliases regardless of launch directory. ([#4055](https://github.com/diegosouzapw/OmniRoute/issues/4055) — thanks @Rahulsharma0810) - **fix(dashboard): Logs page auto-refresh now works in embedded/proxied dashboards** — the Request Logger gated each auto-refresh tick on a static `document.visibilityState === "visible"` read. Hosts that report a permanent non-`"visible"` state without ever firing a `visibilitychange` event (Docker dashboard wrappers, embedded webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: polling starts enabled and only pauses after a real `visibilitychange` → hidden transition (still preserving the backgrounded-tab optimization for normal browser tabs). ([#4054](https://github.com/diegosouzapw/OmniRoute/issues/4054) — thanks @tjengbudi) - **fix(docker): raise the build-stage Node heap to stop the production-build OOM** — the Docker `builder` stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with `FATAL ERROR: … JavaScript heap out of memory` at `[builder] npm run build`. The builder stage now sets `NODE_OPTIONS=--max-old-space-size` (default 4096 MB, overridable via `--build-arg OMNIROUTE_BUILD_MEMORY_MB=…`) before the build; the value propagates to the spawned `next build`. Build-only — the runtime heap (`OMNIROUTE_MEMORY_MB` on the runner stage) is unchanged. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076) — thanks @kamenkadmitry) +- **fix(dashboard): "Update Available" banner reappears reliably across Docker/npm/desktop installs** — the home-page banner is gated on `GET /api/system/version`'s `updateAvailable`, which derived the latest version ONLY from `npm info omniroute version --json` via the `npm` CLI binary. When that binary is absent from the runtime PATH (Docker/desktop/locked-down installs) or the registry is unreachable, the call returned `null` → `updateAvailable=false` → the banner silently never rendered even when a newer release existed. The route now resolves the latest version through `resolveLatestVersion()`: the fast `npm` CLI path first, then an npm-binary-free fallback over the registry HTTP API (`registry.npmjs.org/omniroute/latest`), and a logged warning instead of silent degradation when both fail. Version comparison was also hardened to tolerate `v`-prefixed and pre-release version strings. ([#4100](https://github.com/diegosouzapw/OmniRoute/issues/4100)) --- diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 637b1d34ec0..ee35e4d2eaf 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -17,23 +17,12 @@ import { PROJECT_ROOT, } from "@/lib/system/autoUpdate"; import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; +import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck"; const execFileAsync = promisify(execFile); export const dynamic = "force-dynamic"; -async function getLatestNpmVersion(): Promise { - try { - const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], { - timeout: 10000, - }); - const parsed = JSON.parse(stdout.trim()); - return typeof parsed === "string" ? parsed : null; - } catch { - return null; - } -} - function getCurrentVersion(): string { try { return require("../../../../../package.json").version as string; @@ -42,16 +31,6 @@ function getCurrentVersion(): string { } } -function isNewer(a: string | null, b: string): boolean { - if (!a) return false; - const parse = (v: string) => v.split(".").map(Number); - const [aMaj, aMin, aPat] = parse(a); - const [bMaj, bMin, bPat] = parse(b); - if (aMaj !== bMaj) return aMaj > bMaj; - if (aMin !== bMin) return aMin > bMin; - return aPat > bPat; -} - async function getNews() { try { const res = await fetch(NEWS_JSON_URL, { next: { revalidate: 3600 } }); @@ -72,7 +51,7 @@ export async function GET(req: NextRequest) { const config = getAutoUpdateConfig(); const [latest, news, validation] = await Promise.all([ - getLatestNpmVersion(), + resolveLatestVersion(), getNews(), validateAutoUpdateRuntime(config), ]); @@ -96,7 +75,7 @@ export async function POST(req: NextRequest) { } const current = getCurrentVersion(); - const latest = await getLatestNpmVersion(); + const latest = await resolveLatestVersion(); if (!latest) { return NextResponse.json( diff --git a/src/lib/system/versionCheck.ts b/src/lib/system/versionCheck.ts new file mode 100644 index 00000000000..71bdd2d108a --- /dev/null +++ b/src/lib/system/versionCheck.ts @@ -0,0 +1,113 @@ +/** + * Latest-version discovery + comparison for the dashboard "Update Available" banner. + * + * #4100: the banner is gated on `isNewer(latest, current)`. Previously `latest` came + * ONLY from `npm info omniroute version --json` (the `npm` CLI binary). When that binary + * is absent (Docker / desktop / locked-down installs) or the registry is unreachable, the + * call returned null and the banner silently never rendered — even when an update existed. + * + * This module keeps the fast `npm` CLI path as the primary source but adds an + * npm-binary-free HTTP fallback (the npm registry JSON API, reachable with plain `fetch`) + * and logs a warning instead of degrading silently. Version parsing is also hardened so a + * `v`-prefix or pre-release suffix no longer collapses the comparison to `false` via `NaN`. + */ +import { execFile } from "child_process"; +import { promisify } from "util"; +import { createLogger } from "@/shared/utils/logger"; + +const execFileAsync = promisify(execFile); +const log = createLogger("system/versionCheck"); + +/** npm-binary-free latest-version source: the registry JSON API. */ +const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org/omniroute/latest"; + +const LOOKUP_TIMEOUT_MS = 10_000; + +/** + * Strip a leading `v`, drop pre-release/build metadata (`-`/`+` suffix), split on `.`, + * and return a numeric tuple. Returns null when the string is empty or any segment is + * non-numeric, so callers can fail safe instead of comparing `NaN`. + */ +export function normalizeVersion(v: string): number[] | null { + if (typeof v !== "string") return null; + const cleaned = v.trim().replace(/^v/i, "").split(/[-+]/)[0]; + if (!cleaned) return null; + const parts = cleaned.split(".").map((p) => Number(p)); + if (parts.length === 0 || parts.some((n) => !Number.isFinite(n))) return null; + return parts; +} + +/** + * True iff `latest` is a strictly higher semver than `current`. Safe on null/garbage + * (returns false rather than throwing or yielding a `NaN`-driven false positive). + */ +export function isNewer(latest: string | null | undefined, current: string): boolean { + if (!latest) return false; + const a = normalizeVersion(latest); + const b = normalizeVersion(current); + if (!a || !b) return false; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + if (av !== bv) return av > bv; + } + return false; +} + +/** Latest published version via the `npm` CLI (fast when npm is on PATH, e.g. source installs). */ +export async function getLatestVersionFromNpmCli(): Promise { + try { + const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], { + timeout: LOOKUP_TIMEOUT_MS, + }); + const parsed = JSON.parse(String(stdout).trim()); + return typeof parsed === "string" && parsed ? parsed : null; + } catch { + return null; + } +} + +/** + * Latest published version via the npm registry HTTP API. Needs only network access — no + * `npm` binary — so it works in Docker / desktop / locked-down installs. + */ +export async function getLatestVersionFromRegistry( + fetchImpl: typeof fetch = fetch +): Promise { + try { + const res = await fetchImpl(NPM_REGISTRY_LATEST_URL, { + signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS), + }); + if (!res.ok) return null; + const data = (await res.json()) as { version?: unknown }; + return typeof data?.version === "string" && data.version ? data.version : null; + } catch { + return null; + } +} + +/** + * Resolve the latest published version. Tries the `npm` CLI first (fast on source installs), + * then falls back to the registry HTTP API (npm-binary-free). Logs a warning — instead of + * silently degrading to "no update available" — when BOTH sources fail. Thunks are injectable + * for tests. + */ +export async function resolveLatestVersion(opts?: { + npmCli?: () => Promise; + registry?: () => Promise; +}): Promise { + const npmCli = opts?.npmCli ?? getLatestVersionFromNpmCli; + const registry = opts?.registry ?? (() => getLatestVersionFromRegistry()); + + const viaCli = await npmCli(); + if (viaCli) return viaCli; + + const viaRegistry = await registry(); + if (viaRegistry) return viaRegistry; + + log.warn( + "Latest-version lookup failed via both npm CLI and registry HTTP — the update banner will not show even if a newer release exists" + ); + return null; +} diff --git a/tests/unit/system-version-check-4100.test.ts b/tests/unit/system-version-check-4100.test.ts new file mode 100644 index 00000000000..144eb3c1ce6 --- /dev/null +++ b/tests/unit/system-version-check-4100.test.ts @@ -0,0 +1,77 @@ +/** + * Regression test for #4100 — Home "Update Available" banner no longer appears. + * + * Root cause: `GET /api/system/version` derived `latest` ONLY from `npm info` via the + * `npm` CLI binary, returning null on ANY error (binary missing in Docker/desktop, + * registry unreachable) → updateAvailable=false → banner silently never renders. + * Secondary: `isNewer()`'s `v.split(".").map(Number)` collapsed to false on `v`-prefixed + * or pre-release version strings (NaN comparisons). + * + * These assertions fail against the old inline semantics (no module, fragile isNewer, + * no npm-binary-free fallback) and pass once `src/lib/system/versionCheck.ts` exists. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeVersion, + isNewer, + resolveLatestVersion, +} from "@/lib/system/versionCheck"; + +test("normalizeVersion strips v-prefix, pre-release/build, returns numeric tuple", () => { + assert.deepEqual(normalizeVersion("3.8.28"), [3, 8, 28]); + assert.deepEqual(normalizeVersion("v3.8.28"), [3, 8, 28]); + assert.deepEqual(normalizeVersion("3.8.28-rc.1"), [3, 8, 28]); + assert.deepEqual(normalizeVersion("3.8.28+build.5"), [3, 8, 28]); + assert.equal(normalizeVersion(""), null); + assert.equal(normalizeVersion("not-a-version"), null); +}); + +test("isNewer: basic ordering and null safety", () => { + assert.equal(isNewer("3.8.29", "3.8.28"), true); + assert.equal(isNewer("3.8.28", "3.8.28"), false); + assert.equal(isNewer("3.8.27", "3.8.28"), false); + assert.equal(isNewer(null, "3.8.28"), false); +}); + +test("isNewer: v-prefixed latest is handled (#4100 — old code returned false via NaN)", () => { + assert.equal(isNewer("v3.8.29", "3.8.28"), true); +}); + +test("isNewer: pre-release suffix is handled (#4100 — old code returned false via NaN)", () => { + assert.equal(isNewer("3.8.29-rc.1", "3.8.28"), true); +}); + +test("isNewer: multi-digit minor ordering", () => { + assert.equal(isNewer("3.10.0", "3.9.9"), true); + assert.equal(isNewer("3.9.9", "3.10.0"), false); +}); + +test("resolveLatestVersion falls back to the registry when the npm CLI path fails (#4100)", async () => { + const latest = await resolveLatestVersion({ + npmCli: async () => null, // npm binary missing / registry unreachable via CLI + registry: async () => "3.8.29", // npm-binary-free HTTP fallback succeeds + }); + assert.equal(latest, "3.8.29"); +}); + +test("resolveLatestVersion prefers the npm CLI when it succeeds", async () => { + let registryCalled = false; + const latest = await resolveLatestVersion({ + npmCli: async () => "3.8.30", + registry: async () => { + registryCalled = true; + return "3.8.29"; + }, + }); + assert.equal(latest, "3.8.30"); + assert.equal(registryCalled, false); +}); + +test("resolveLatestVersion returns null when both sources fail (no silent crash)", async () => { + const latest = await resolveLatestVersion({ + npmCli: async () => null, + registry: async () => null, + }); + assert.equal(latest, null); +});