From a8f24b2d040aee6821cb565fa814c30f4f3b1a13 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 11:12:12 +0700 Subject: [PATCH 1/2] fix(install): recover CoDev Code's native binary instead of dead-ending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codev-code` ships the same shape as Claude Code: a tiny placeholder at `bin/codev.exe` plus a postinstall that copies the real ~170 MB binary out of a platform-specific optionalDependency. When that postinstall doesn't place the binary, the placeholder stays — 476 bytes of POSIX shell under an `.exe` name — and npm still exits 0, because the download it skipped was optional. `installAndVerify` then runs `codev --version` and gets, on Windows: This version of C:\Program Files\nodejs\node_modules\codev-code\bin\ codev.exe is not compatible with the version of Windows you're running. That is the PE loader refusing a file with no PE header, but it reads as "wrong architecture" or "unsupported Windows", so users go audit their OS and find nothing. `installAndVerify` already recovers this exact class of failure for `claude-code` and for `codex` on Windows, but had nothing for `codev-code` — which ToolSelect locks into every selection, so the failed row empties the survivor set and `SetupApp` parks on `install-failed` with nothing installed and no way forward. - Add `recoverCodevNativeBinary`, mirroring the Claude Code recovery: re-run the package's own `postinstall.mjs`, re-verify, then force a reinstall and re-verify. Verified end-to-end against a real broken install: 476-byte placeholder → 176 MB binary → `--version` answers. - The recovery reinstall carries `--force`. npm re-runs install scripts only when it considers the tree changed, so once the placeholder is in place a plain `npm i -g codev-code` can report success without touching it, and every retry reproduces the same failure. - Report the placeholder rather than the loader error, but only when the size probe positively confirms it. A full-size binary that still won't run is a genuine incompatibility and keeps its own message. - Add the matching launch-time hint in `runAgent`, alongside Claude's. Also fix a second bug visible in the same report: `verifyInstall` ran the bare CLI name through PATH, so on a machine that had installed before, `codev` resolved to CoDev's own `~/.codev-hub/bin/codev.cmd`, which re-execs `codevhub codev --version`. Verification measured our shim and prefixed the agent's real error with our "Starting CoDev Code..." banner — the line users reported inside the install error. It now strips the shim dir from the child's PATH, exactly as `run.ts` does when launching an agent for real. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/npm.ts | 177 +++++++++++++++++++++++++-- src/lib/run.ts | 41 +++++-- tests/lib/npm.test.ts | 272 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 464 insertions(+), 26 deletions(-) diff --git a/src/lib/npm.ts b/src/lib/npm.ts index 2efcf3e..473f722 100644 --- a/src/lib/npm.ts +++ b/src/lib/npm.ts @@ -3,6 +3,7 @@ import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import type { Tool } from "@/lib/configure.js"; import { logDebug, logWarn } from "@/lib/log.js"; +import { stripShimDirFromPath } from "@/lib/shims.js"; // Tools installed via npm-global. Extension/plugin variants (Claude Code + // Continue) are not npm packages — VS Code installs them via @@ -95,7 +96,14 @@ export function execAsync( // `inheritStdin` hands the child our real stdin instead of a pipe. Only the // console-mode probe needs it: it reads the Windows console input mode // through its stdin handle, and a piped stdin is not a console. - options: { inheritStdin?: boolean } = {}, + // + // `env` replaces the child's environment wholesale (Node's default is to + // inherit ours, so callers pass a spread of `process.env`). Only + // `verifyInstall` needs it, to drop our own PATH shim dir — see there. + options: { + inheritStdin?: boolean; + env?: NodeJS.ProcessEnv; + } = {}, ): Promise { // Every child process codev shells out to funnels through here (npm, the // agent --version probes, `code --install-extension`, JetBrains CLIs, @@ -172,8 +180,12 @@ export function execAsync( ? spawn(shellCommand, { stdio: ["inherit", "pipe", "pipe"], shell: true, + ...(options.env ? { env: options.env } : {}), }) - : spawn(file, args, { stdio: ["inherit", "pipe", "pipe"] }); + : spawn(file, args, { + stdio: ["inherit", "pipe", "pipe"], + ...(options.env ? { env: options.env } : {}), + }); const out: Buffer[] = []; const err: Buffer[] = []; child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)); @@ -199,17 +211,23 @@ export function execAsync( return; } + const envOption = options.env ? { env: options.env } : {}; + if (USE_SHELL) { execFile( shellCommand, - { shell: true, encoding: "utf-8" }, + { shell: true, encoding: "utf-8", ...envOption }, (err, stdout, stderr) => done(err as NodeJS.ErrnoException | null, stdout, stderr), ); return; } - execFile(file, args, { encoding: "utf-8" }, (err, stdout, stderr) => - done(err as NodeJS.ErrnoException | null, stdout, stderr), + execFile( + file, + args, + { encoding: "utf-8", ...envOption }, + (err, stdout, stderr) => + done(err as NodeJS.ErrnoException | null, stdout, stderr), ); }); } @@ -235,8 +253,19 @@ function installSpec(tool: NpmTool): string { // `claude` fails at runtime with "claude native binary not installed". const HARDENING_FLAGS = ["--include=optional", "--ignore-scripts=false"]; -export async function installPackage(pkg: string): Promise { - const r = await execAsync("npm", ["i", "-g", pkg, ...HARDENING_FLAGS]); +export async function installPackage( + pkg: string, + // Appended after the hardening flags. Recovery paths use it to add + // `--force`; ordinary installs pass nothing. + extraFlags: string[] = [], +): Promise { + const r = await execAsync("npm", [ + "i", + "-g", + pkg, + ...HARDENING_FLAGS, + ...extraFlags, + ]); if (!r.error) return null; return r.stderr.trim() || r.error.message; } @@ -248,8 +277,21 @@ export async function npmGlobalRoot(): Promise { return root || null; } +// Probe the freshly-installed agent by asking it for its version. +// +// The child's PATH has ~/.codev-hub/bin stripped, because that directory holds +// CoDev's own shims: on a machine that has installed before, a bare `codev` +// resolves to `codev.cmd`, which re-execs `codevhub codev --version`, which +// runs `runAgent` — so verification would spawn a whole second hub process and +// prefix the agent's real error with our own "Starting CoDev Code..." banner. +// That banner is exactly what users reported seeing inside the install error. +// `run.ts` strips the same directory for the same reason when it launches an +// agent for real; verification has to match, or it measures the shim rather +// than the binary npm just wrote. export async function verifyInstall(tool: NpmTool): Promise { - const r = await execAsync(CLI[tool], ["--version"]); + const r = await execAsync(CLI[tool], ["--version"], { + env: { ...process.env, PATH: stripShimDirFromPath(process.env.PATH) }, + }); if (!r.error) return null; return r.stderr.trim() || r.error.message; } @@ -264,6 +306,24 @@ export async function runClaudePostinstall(): Promise { return r.stderr.trim() || r.error.message; } +// CoDev Code's sibling of runClaudePostinstall. `codev-code` ships the same +// shape as Claude Code — a placeholder at bin/codev.exe plus a postinstall that +// copies the real binary out of a platform-specific optionalDependency — so it +// needs the same escape hatch. Running the script directly is the one recovery +// that works no matter what npm decided: npm re-runs install scripts only when +// it considers the tree changed, so a repeat `npm i -g codev-code` over an +// already-current install can report success without ever touching the +// placeholder. `node postinstall.mjs` doesn't ask npm's opinion. +export async function runCodevPostinstall(): Promise { + const root = await npmGlobalRoot(); + if (!root) return "could not resolve npm root -g"; + const script = join(root, PKG["codev-code"], "postinstall.mjs"); + if (!existsSync(script)) return `${script} does not exist`; + const r = await execAsync("node", [script]); + if (!r.error) return null; + return r.stderr.trim() || r.error.message; +} + // Claude Code ships a tiny (~4 KB) placeholder at bin/claude.exe and downloads // the real (hundreds of MB) native binary as a platform-specific // optionalDependency, which its postinstall copies over the placeholder. When @@ -278,8 +338,40 @@ export async function claudeNativeBinaryMissing(): Promise { const root = await npmGlobalRoot(); if (!root) return false; const bin = join(root, "@anthropic-ai", "claude-code", "bin", "claude.exe"); + return isPlaceholderStub(bin); +} + +// CoDev Code's sibling of claudeNativeBinaryMissing, and the probe behind the +// Windows failure this recovery exists for. `codev-code`'s placeholder is a +// 476-byte POSIX shell script that npm nonetheless installs — and links a +// `codev.cmd` shim to — under the name `bin/codev.exe`, because that is the +// package's declared `bin` on every platform. Unix reads the shebang-less text +// as a shell script and prints its "postinstall script was not run" message; +// Windows hands the .exe to the PE loader, which rejects a file that has no PE +// header and reports it through cmd.exe as: +// +// This version of C:\...\codev-code\bin\codev.exe is not compatible with the +// version of Windows you're running. +// +// That message names neither npm nor a postinstall, so it reads as "wrong +// architecture" or "unsupported Windows" and sends users chasing their OS +// version. The size probe is what lets us say what actually happened. Same +// conservative default as its Claude sibling: anything we can't resolve is a +// `false`, so a genuinely broken binary is never mislabeled a missing one. +export async function codevNativeBinaryMissing(): Promise { + const root = await npmGlobalRoot(); + if (!root) return false; + const bin = join(root, PKG["codev-code"], "bin", "codev.exe"); + return isPlaceholderStub(bin); +} + +// Both agents ship a placeholder small enough that no real native binary could +// be confused for it (Claude's stub is ~4 KB, CoDev Code's is 476 bytes; the +// binaries they stand in for are 170-250 MB). A file we can't stat is not a +// confirmed stub, so it reports false. +function isPlaceholderStub(path: string): boolean { try { - return statSync(bin).size < 4096; + return statSync(path).size < 4096; } catch { return false; } @@ -315,6 +407,66 @@ async function recoverClaudeNativeBinary( return `installed but '${cli}' fails (${firstVerify}); recovery reinstall failed: ${reinstallErr}`; } +// Recovery for a CoDev Code install whose native binary never got placed. Same +// two root causes as Claude Code's, and the same cheapest-first order, with one +// addition that the Claude path doesn't need. +// +// The extra case is npm's own idempotency. `npm i -g codev-code` over a tree npm +// already considers current can finish without re-running install scripts, so +// once bin/codev.exe is left as the placeholder, every subsequent +// `codevhub install` re-reports the same failure and exits 0 from npm — the +// user is stuck in a loop no amount of retrying escapes. Stage 1 sidesteps npm +// entirely by running postinstall.mjs itself, and stage 2's reinstall carries +// `--force` so npm re-fetches and re-links rather than declaring the tree +// already correct. +// +// Reported failures name the placeholder rather than echoing the raw loader +// error, which on Windows blames the OS for something npm did. +async function recoverCodevNativeBinary( + firstVerify: string, +): Promise { + const cli = CLI["codev-code"]; + // Captured before the repair attempts, which are what change the answer. + const wasPlaceholder = await codevNativeBinaryMissing(); + + const postErr = await runCodevPostinstall(); + if (!postErr) { + const afterPost = await verifyInstall("codev-code"); + if (!afterPost) return null; + } + + const reinstallErr = await installPackage(installSpec("codev-code"), [ + "--force", + ]); + if (!reinstallErr) { + const afterReinstall = await verifyInstall("codev-code"); + if (!afterReinstall) return null; + return `installed but '${cli}' still fails after recovery (postinstall + reinstall): ${describeCodevFailure(wasPlaceholder, afterReinstall)}`; + } + return `installed but '${cli}' fails (${describeCodevFailure(wasPlaceholder, firstVerify)}); recovery reinstall failed: ${reinstallErr}`; +} + +// Replace the platform's own wording with what actually went wrong, when we +// have positively confirmed the placeholder is still in place. Windows' loader +// error ("This version of …\codev.exe is not compatible with the version of +// Windows you're running") is the one users report, and it points at the OS +// rather than at the postinstall that never ran — so a user who follows it +// checks their Windows build and finds nothing wrong. Without that +// confirmation the agent's own message is passed through untouched: a real +// architecture or OS mismatch would produce the same text, and overriding it +// would be the same mistake in reverse. +function describeCodevFailure(wasPlaceholder: boolean, reason: string): string { + if (!wasPlaceholder) return reason; + return ( + "CoDev Code's native binary was never unpacked — bin/codev.exe is still " + + "the placeholder stub, so it isn't a runnable program. This usually means " + + "npm skipped the package's postinstall script, or the platform-specific " + + "download (~170 MB) was blocked. Retry on a connection that can reach the " + + "npm registry, or install it by hand with " + + `\`npm i -g ${PKG["codev-code"]} --include=optional --ignore-scripts=false --force\`.` + ); +} + // Codex's npm package resolves its native binary via an `optionalDependencies` // entry that uses an `npm:` alias against a dist-tagged version of the same // package (e.g. `@openai/codex-win32-x64@npm:@openai/codex@-win32-x64`). @@ -357,6 +509,13 @@ export async function installAndVerify(tool: NpmTool): Promise { return recoverClaudeNativeBinary(firstVerify); } + // codev-code ships the same placeholder-plus-postinstall shape, and it is + // the one agent ToolSelect locks on, so a bare failure here parks the whole + // wizard on `install-failed` with nothing installed. Recover it too. + if (tool === "codev-code") { + return recoverCodevNativeBinary(firstVerify); + } + if (tool === "codex" && process.platform === "win32") { const recoveryErr = await runCodexWindowsRecovery(); if (!recoveryErr) { diff --git a/src/lib/run.ts b/src/lib/run.ts index 0615885..b1dd64d 100644 --- a/src/lib/run.ts +++ b/src/lib/run.ts @@ -3,7 +3,10 @@ import { accessSync, constants as fsConstants } from "node:fs"; import { constants } from "node:os"; import { delimiter, join } from "node:path"; import { logError, logInfo, logWarn } from "@/lib/log.js"; -import { claudeNativeBinaryMissing } from "@/lib/npm.js"; +import { + claudeNativeBinaryMissing, + codevNativeBinaryMissing, +} from "@/lib/npm.js"; import { stripShimDirFromPath } from "@/lib/shims.js"; const AGENT_LABEL: Record = { @@ -158,21 +161,35 @@ export function runAgent(cmd: string, args: string[]): Promise { child.once("exit", async (code, signal) => { cleanup(); if (code !== null) { - // A non-zero `claude` exit can be the leftover placeholder stub - // erroring with "native binary not installed" (suppressed - // postinstall / omitted optional dependency). The stub already - // printed its own message via inherited stderr; we only add a - // codev-specific repair hint when we can positively confirm the - // native binary is missing, so normal claude failures stay quiet. - if ( - code !== 0 && - cmd === "claude" && - (await claudeNativeBinaryMissing()) - ) { + // A non-zero `claude` or `codev` exit can be the leftover + // placeholder stub rather than the agent (suppressed postinstall / + // omitted optional dependency). We only add a repair hint when we + // can positively confirm the native binary is missing, so ordinary + // agent failures stay quiet. + // + // The two stubs fail very differently and only one of them explains + // itself. Claude's prints "native binary not installed" through the + // inherited stderr, so the hint just adds the fix. CoDev Code's + // placeholder is a shell script that npm installs as `codev.exe`, + // so on Windows the user gets a PE-loader error blaming their + // Windows version and no clue that a download was skipped — there + // the hint carries the diagnosis as well. + const stubbedAgent = + code !== 0 && (cmd === "claude" || cmd === "codev") ? cmd : null; + if (stubbedAgent === "claude" && (await claudeNativeBinaryMissing())) { process.stderr.write( "\nclaude's native binary is missing. Run 'codevhub install' to repair it " + "(reinstalls Claude Code with the platform binary included).\n", ); + } else if ( + stubbedAgent === "codev" && + (await codevNativeBinaryMissing()) + ) { + process.stderr.write( + "\nCoDev Code's native binary is missing — bin/codev.exe is still the " + + "placeholder stub, which is why it won't start. Run 'codevhub install' " + + "to repair it (re-runs the postinstall and reinstalls the platform binary).\n", + ); } (code === 0 ? logInfo : logWarn)(`${label} exited (code ${code})`, { action: "process.exit", diff --git a/tests/lib/npm.test.ts b/tests/lib/npm.test.ts index acd9e0b..68002b6 100644 --- a/tests/lib/npm.test.ts +++ b/tests/lib/npm.test.ts @@ -1,9 +1,10 @@ import * as child_process from "node:child_process"; import * as fs from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import { claudeNativeBinaryMissing, + codevNativeBinaryMissing, detectInstalledViaNpm, installAndVerify, installPackage, @@ -11,6 +12,7 @@ import { npmGlobalRoot, verifyInstall, } from "@/lib/npm.js"; +import { shimDir } from "@/lib/shims.js"; // ESM module namespaces are frozen — vi.spyOn can't redefine `execFile` / // `existsSync` directly. We replace them up-front with vi.fn() via vi.mock() @@ -34,6 +36,9 @@ type ExecCb = (error: Error | null, stdout: string, stderr: string) => void; interface ExecCall { file: string; args: string[]; + // The child's environment, when the caller overrode it. `verifyInstall` is + // the only production caller that does. + env?: NodeJS.ProcessEnv; } interface StubOptions { @@ -54,16 +59,21 @@ interface StubOptions { function normalizeExecFileCall(callArgs: unknown[]): { file: string; args: string[]; + env?: NodeJS.ProcessEnv; cb: ExecCb; } { const cb = callArgs[callArgs.length - 1] as ExecCb; + const opts = callArgs[callArgs.length - 2] as + | { env?: NodeJS.ProcessEnv } + | undefined; + const env = opts?.env; const first = callArgs[0] as string; const second = callArgs[1]; if (Array.isArray(second)) { - return { file: first, args: second as string[], cb }; + return { file: first, args: second as string[], env, cb }; } const tokens = first.split(/\s+/).filter(Boolean); - return { file: tokens[0] ?? "", args: tokens.slice(1), cb }; + return { file: tokens[0] ?? "", args: tokens.slice(1), env, cb }; } function stubExecFile(opts: StubOptions): ExecCall[] { @@ -71,8 +81,8 @@ function stubExecFile(opts: StubOptions): ExecCall[] { vi.mocked(child_process.execFile).mockImplementation((( ...callArgs: unknown[] ) => { - const { file, args, cb } = normalizeExecFileCall(callArgs); - calls.push({ file, args }); + const { file, args, env, cb } = normalizeExecFileCall(callArgs); + calls.push({ file, args, env }); const r = opts.handler(file, args); setImmediate(() => cb(r.error ?? null, r.stdout ?? "", r.stderr ?? "")); return {} as unknown as child_process.ChildProcess; @@ -173,6 +183,25 @@ describe("npm.ts", () => { const err = await verifyInstall("claude-code"); expect(err).toBe("spawn claude ENOENT"); }); + + test("strips CoDev's shim dir from the child's PATH", async () => { + // Without this, `codev --version` resolves ~/.codev-hub/bin/codev.cmd, + // which re-execs `codevhub codev` and reports on our own shim instead + // of the binary npm just installed — the observed symptom was the + // agent's error arriving prefixed with "Starting CoDev Code...". + const origPath = process.env.PATH; + process.env.PATH = [shimDir(), "/usr/bin", "/bin"].join(delimiter); + try { + const calls = stubExecFile({ handler: () => ({ stdout: "1.0.0" }) }); + await verifyInstall("codev-code"); + const dirs = (calls[0]?.env?.PATH ?? "").split(delimiter); + expect(dirs).not.toContain(shimDir()); + // The rest of PATH survives — the agent still has to be findable. + expect(dirs).toContain("/usr/bin"); + } finally { + process.env.PATH = origPath; + } + }); }); describe("installAndVerify", () => { @@ -556,6 +585,198 @@ describe("npm.ts", () => { } }); }); + + describe("codev-code native binary recovery", () => { + const postinstall = join("/fake/root", "codev-code", "postinstall.mjs"); + const binPath = join("/fake/root", "codev-code", "bin", "codev.exe"); + + // The placeholder codev-code leaves at bin/codev.exe when its + // postinstall never places the real binary. 476 bytes of shell script + // under an .exe name — which is why Windows rejects it as an + // incompatible executable rather than reporting a failed install. + function stubPlaceholder(size = 476) { + return vi + .mocked(fs.statSync) + .mockImplementation( + (pth: fs.PathLike) => + (String(pth) === binPath ? { size } : { size: 0 }) as fs.Stats, + ); + } + + test("re-runs the package's own postinstall and re-verifies", async () => { + let codevCalls = 0; + const existsSpy = vi + .mocked(fs.existsSync) + .mockImplementation(() => true); + const statSpy = stubPlaceholder(); + const calls = stubExecFile({ + handler: (file, args) => { + if (file === "npm" && args[0] === "i") return { stdout: "ok" }; + if (file === "npm" && args[0] === "root") { + return { stdout: "/fake/root" }; + } + if (file === "node") return { stdout: "postinstall ok" }; + if (file === "codev") { + codevCalls += 1; + if (codevCalls === 1) { + return { + error: new Error("bad exe"), + stderr: "not compatible", + }; + } + return { stdout: "1.18.4" }; + } + return { stdout: "" }; + }, + }); + const err = await installAndVerify("codev-code"); + expect(err).toBeNull(); + expect(codevCalls).toBe(2); + // Run the script directly rather than asking npm to re-run it: npm + // skips install scripts for a tree it already considers current, so + // the placeholder would survive every retry. + const nodeCall = calls.find((c) => c.file === "node"); + expect(nodeCall?.args).toEqual([postinstall]); + statSpy.mockRestore(); + existsSpy.mockRestore(); + }); + + test("forces the recovery reinstall so npm can't call the tree current", async () => { + let codevCalls = 0; + const existsSpy = vi + .mocked(fs.existsSync) + .mockImplementation(() => true); + const statSpy = stubPlaceholder(); + const calls = stubExecFile({ + handler: (file, args) => { + if (file === "npm" && args[0] === "i") return { stdout: "ok" }; + if (file === "npm" && args[0] === "root") { + return { stdout: "/fake/root" }; + } + // The postinstall exits 0 without placing anything (the + // platform package was never downloaded), so only a + // re-fetching reinstall can fix it. + if (file === "node") return { stdout: "" }; + if (file === "codev") { + codevCalls += 1; + if (codevCalls <= 2) { + return { + error: new Error("bad exe"), + stderr: "not compatible", + }; + } + return { stdout: "1.18.4" }; + } + return { stdout: "" }; + }, + }); + const err = await installAndVerify("codev-code"); + expect(err).toBeNull(); + const installs = calls.filter( + (c) => c.file === "npm" && c.args[0] === "i", + ); + expect(installs.length).toBe(2); + // The first install is the ordinary one and must stay unforced. + expect(installs[0]?.args).not.toContain("--force"); + expect(installs[1]?.args).toContain("--force"); + // --force never displaces the hardening flags. + expect(installs[1]?.args).toContain("--include=optional"); + expect(installs[1]?.args).toContain("--ignore-scripts=false"); + statSpy.mockRestore(); + existsSpy.mockRestore(); + }); + + test("explains the placeholder instead of echoing the OS loader error", async () => { + const existsSpy = vi + .mocked(fs.existsSync) + .mockImplementation(() => true); + const statSpy = stubPlaceholder(); + stubExecFile({ + handler: (file, args) => { + if (file === "npm" && args[0] === "i") return { stdout: "ok" }; + if (file === "npm" && args[0] === "root") { + return { stdout: "/fake/root" }; + } + if (file === "node") return { stdout: "" }; + if (file === "codev") { + return { + error: new Error("bad exe"), + stderr: + "This version of C:\\Program Files\\nodejs\\node_modules\\codev-code\\bin\\codev.exe is not compatible with the version of Windows you're running.", + }; + } + return { stdout: "" }; + }, + }); + const err = await installAndVerify("codev-code"); + // The Windows text blames the OS for a download that never happened, + // so the report has to name the real cause and a way out. + expect(err).toContain("native binary was never unpacked"); + expect(err).toContain("placeholder stub"); + expect(err).toContain("npm i -g codev-code"); + statSpy.mockRestore(); + existsSpy.mockRestore(); + }); + + test("passes the agent's own error through when the binary is real", async () => { + // A full-size binary that still won't run is a genuine incompatibility + // (wrong architecture, unsupported OS). Rewriting that as "the + // postinstall didn't run" would send the user after the wrong fix. + const existsSpy = vi + .mocked(fs.existsSync) + .mockImplementation(() => true); + const statSpy = stubPlaceholder(177_637_504); + stubExecFile({ + handler: (file, args) => { + if (file === "npm" && args[0] === "i") return { stdout: "ok" }; + if (file === "npm" && args[0] === "root") { + return { stdout: "/fake/root" }; + } + if (file === "node") return { stdout: "" }; + if (file === "codev") { + return { error: new Error("x"), stderr: "Illegal instruction" }; + } + return { stdout: "" }; + }, + }); + const err = await installAndVerify("codev-code"); + expect(err).toContain("Illegal instruction"); + expect(err).not.toContain("placeholder stub"); + statSpy.mockRestore(); + existsSpy.mockRestore(); + }); + + test("surfaces a failed recovery reinstall", async () => { + let npmInstalls = 0; + const existsSpy = vi + .mocked(fs.existsSync) + .mockImplementation(() => true); + const statSpy = stubPlaceholder(); + stubExecFile({ + handler: (file, args) => { + if (file === "npm" && args[0] === "i") { + npmInstalls += 1; + if (npmInstalls === 1) return { stdout: "ok" }; + return { error: new Error("x"), stderr: "registry offline" }; + } + if (file === "npm" && args[0] === "root") { + return { stdout: "/fake/root" }; + } + if (file === "node") { + return { error: new Error("x"), stderr: "postinstall failed" }; + } + if (file === "codev") { + return { error: new Error("bad exe"), stderr: "not compatible" }; + } + return { stdout: "" }; + }, + }); + const err = await installAndVerify("codev-code"); + expect(err).toContain("recovery reinstall failed: registry offline"); + statSpy.mockRestore(); + existsSpy.mockRestore(); + }); + }); }); describe("claudeNativeBinaryMissing", () => { @@ -603,6 +824,47 @@ describe("npm.ts", () => { }); }); + describe("codevNativeBinaryMissing", () => { + // codev-code declares bin/codev.exe on every platform, so the probe uses + // the same path everywhere — matching the package's own bin field. + const binPath = join("/fake/root", "codev-code", "bin", "codev.exe"); + + test("true when the placeholder stub is still in place", async () => { + stubExecFile({ handler: () => ({ stdout: "/fake/root" }) }); + const statSpy = vi + .mocked(fs.statSync) + .mockImplementation( + (p: fs.PathLike) => + (String(p) === binPath ? { size: 476 } : { size: 0 }) as fs.Stats, + ); + expect(await codevNativeBinaryMissing()).toBe(true); + statSpy.mockRestore(); + }); + + test("false when the real native binary is in place", async () => { + stubExecFile({ handler: () => ({ stdout: "/fake/root" }) }); + const statSpy = vi + .mocked(fs.statSync) + .mockImplementation(() => ({ size: 177_637_504 }) as fs.Stats); + expect(await codevNativeBinaryMissing()).toBe(false); + statSpy.mockRestore(); + }); + + test("false (stay quiet) when the binary path can't be stat'd", async () => { + stubExecFile({ handler: () => ({ stdout: "/fake/root" }) }); + const statSpy = vi.mocked(fs.statSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(await codevNativeBinaryMissing()).toBe(false); + statSpy.mockRestore(); + }); + + test("false (stay quiet) when npm root -g fails", async () => { + stubExecFile({ handler: () => ({ error: new Error("boom") }) }); + expect(await codevNativeBinaryMissing()).toBe(false); + }); + }); + describe("isPackageInstalledGlobally", () => { test("returns true when the package dir exists under npm root", async () => { stubExecFile({ handler: () => ({ stdout: "/fake/root" }) }); From d4d6ddec407b231deb4a53390b442b683714babf Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Wed, 19 Aug 2026 11:20:50 +0700 Subject: [PATCH 2/2] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac3bd1e..2ea0d8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codev-ai", - "version": "0.5.16", + "version": "0.5.17", "description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.", "keywords": [ "ai",