diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7f9d7113..3dc8b84d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → *"Failed to create backup. Aborting"*. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) - **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) - **fix(dev):** auto-rebuild `better-sqlite3` on a Node ABI mismatch at `npm run dev` startup (nvm 22↔24) — dev-only, no-op on the healthy path, unrelated errors not swallowed. ([#3301](https://github.com/diegosouzapw/OmniRoute/pull/3301) — thanks @zhiru) diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index 5fe56e8c49..e3c95cfd81 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -1,16 +1,24 @@ import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { homedir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { t } from "../i18n.mjs"; const execFileAsync = promisify(execFile); -async function getCurrentVersion() { +// This file lives at /bin/cli/commands/update.mjs — resolve package +// paths relative to the script, NOT process.cwd(). On a global npm/brew install +// the user's cwd is not the package root, so cwd-relative lookups break (#3295). +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(SCRIPT_DIR, "..", "..", ".."); +const BIN_DIR = path.join(PKG_ROOT, "bin"); + +export async function getCurrentVersion() { try { const { readFileSync } = await import("node:fs"); - const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf-8")); + const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8")); return pkg.version; } catch { return null; @@ -38,12 +46,12 @@ function compareVersions(a, b) { return 0; } -async function createBackup() { - const binPath = path.join(process.cwd(), "bin"); +export async function createBackup() { + const binPath = BIN_DIR; const backupDir = path.join(homedir(), ".omniroute", "backups", `omniroute-${Date.now()}`); try { - const { mkdirSync, copyFileSync, existsSync } = await import("node:fs"); + const { mkdirSync, cpSync, existsSync } = await import("node:fs"); if (!existsSync(binPath)) return null; mkdirSync(backupDir, { recursive: true }); @@ -51,7 +59,9 @@ async function createBackup() { for (const f of files) { const src = path.join(binPath, f); if (existsSync(src)) { - copyFileSync(src, path.join(backupDir, f)); + // cpSync handles both files and directories; the old copyFileSync threw + // EISDIR on the "cli" directory, which was swallowed by the catch (#3295). + cpSync(src, path.join(backupDir, f), { recursive: true }); } } return backupDir; diff --git a/tests/unit/cli-update-global-paths-3295.test.ts b/tests/unit/cli-update-global-paths-3295.test.ts new file mode 100644 index 0000000000..e23a2ef3dd --- /dev/null +++ b/tests/unit/cli-update-global-paths-3295.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, mkdirSync, existsSync, statSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const update = await import("../../bin/cli/commands/update.mjs"); + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REAL_VERSION = JSON.parse( + readFileSync(path.join(REPO_ROOT, "package.json"), "utf-8") +).version; + +// #3295 issue 1: getCurrentVersion() must resolve package.json relative to the +// script, not process.cwd(). When OmniRoute is installed globally, the user's +// cwd is not the package root, so a cwd-relative lookup returns null → +// "Could not determine current version". +test("getCurrentVersion resolves the real version from a foreign cwd (#3295)", async () => { + const originalCwd = process.cwd(); + const foreignCwd = mkdtempSync(path.join(tmpdir(), "omniroute-cwd-")); + try { + process.chdir(foreignCwd); // no package.json here → cwd-relative lookup would fail + const version = await update.getCurrentVersion(); + assert.equal(version, REAL_VERSION); + } finally { + process.chdir(originalCwd); + rmSync(foreignCwd, { recursive: true, force: true }); + } +}); + +// #3295 issue 2: createBackup() must (a) resolve bin/ relative to the script, +// and (b) copy the "cli" directory recursively. The old copyFileSync(dir) threw +// EISDIR which the outer catch swallowed → "Failed to create backup. Aborting". +test("createBackup resolves bin/ from a foreign cwd and copies cli/ recursively (#3295)", async () => { + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + const foreignCwd = mkdtempSync(path.join(tmpdir(), "omniroute-cwd-")); + const fakeHome = mkdtempSync(path.join(tmpdir(), "omniroute-home-")); + try { + process.chdir(foreignCwd); // no bin/ here → cwd-relative binPath would be missing + process.env.HOME = fakeHome; // redirect ~/.omniroute/backups + mkdirSync(fakeHome, { recursive: true }); + + const backupDir = await update.createBackup(); + + assert.ok(backupDir, "createBackup must return a path (not null)"); + // omniroute.mjs is a real file in bin/ and must be copied + assert.ok(existsSync(path.join(backupDir, "omniroute.mjs")), "omniroute.mjs copied"); + // "cli" is a directory — it must be copied recursively, not throw EISDIR + const cliBackup = path.join(backupDir, "cli"); + assert.ok(existsSync(cliBackup), "cli/ directory copied"); + assert.ok(statSync(cliBackup).isDirectory(), "cli/ backup is a directory"); + assert.ok( + existsSync(path.join(cliBackup, "commands")), + "cli/ contents copied recursively" + ); + } finally { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(foreignCwd, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + } +});