From 673c667c0234b701327e67a3a7756bd3fe88533d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 15:49:00 +0700 Subject: [PATCH 1/2] fix(opencode): make the npm postinstall failure diagnosable and its Windows fallback work The postinstall swallowed every error and ended with a message that only named packages to retry, so an install that failed because the platform package never arrived, because the fallback npm fetch failed, or because the installed binary refused to run all looked identical. Each step now records a one-line reason (spawn error, exit code or signal, first stderr line of `--version`) and the final error lists them under "What was tried". The fallback fetch spawned `npm` without a shell; on Windows that is npm.cmd, which Node has refused to spawn without a shell since 18.20.2 / 20.12.2 (CVE-2024-27980), so the fallback silently never ran there. It now goes through cmd.exe as the Node docs recommend. The placeholder stub that runs when the postinstall did not points npm 12 users at --allow-scripts, since npm 12 blocks install scripts on global installs by default. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 17 +++++ DIVERGENCES.md | 4 +- packages/opencode/script/postinstall.mjs | 86 +++++++++++++++++++----- packages/opencode/script/publish.ts | 2 + 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 196df50e4c..33b1437530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1408,6 +1408,23 @@ not found`. Tabs belonging to a removed server are now closed before its elevation prompt. ([#202](https://github.com/quickbeard/codev-code/pull/202)) +### Install + +- **`npm install -g codev-code` says what went wrong** — when the platform + binary could not be set up, the install used to end with only "It seems your + package manager failed to install the right codev CLI package", whether the + platform package had never been downloaded, the fallback download had failed, + or the binary had been installed but refused to run (an antivirus or + application-control policy blocking an executable under `%APPDATA%`, say). + The message now ends with a **What was tried** list naming each package and + the exact step that failed, including the binary's own exit code and error + line. The fallback that fetches the platform package directly when the + package manager skipped it also works on Windows now: it invoked `npm` + without a shell, which current Node versions refuse for `npm.cmd`, so on + Windows it had never actually run. Installs on npm 12, which blocks install + scripts on global installs by default, are told the `--allow-scripts` + flag to use when the un-set-up placeholder is run. + ## 1.18.4-8 ### Desktop / Web UI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d29be08a87..2f65fbe0a2 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -126,9 +126,9 @@ from the squash message first, or use a plain merge. | `packages/tui/src/terminal-win32.ts`, `packages/opencode/src/cli/cmd/debug/index.ts` | terminal-win32.ts: added a `win32ConsoleMode()` export returning the console input mode read through the guard's own kernel32 handle (undefined under the same conditions that stop `win32InstallCtrlCGuard` installing). debug/index.ts: registered the fork-only `ConsoleCommand` (`codev debug console`, `packages/opencode/src/cli/cmd/debug/console.ts`). | Windows users whose console keeps ENABLE_PROCESSED_INPUT set get ctrl+c as a console break rather than a keypress, which strands the cmd.exe shims in a hub launch chain at "Terminate batch job (Y/N)?" and looks like a frozen terminal. `codev debug console` reads the mode, installs the real guard, re-reads to prove the bit actually clears, then unhooks and re-reads — so an affected machine reports itself instead of needing a maintainer to bisect it. `codevhub doctor` relays the JSON form as its `console-ctrl-c` check. | Keep the `win32ConsoleMode` export and the one-line command registration; take upstream's surrounding changes. Both collapse to nothing if upstream ever ships its own console diagnostic. | | `packages/tui/src/terminal-win32.ts`, `packages/tui/src/app.tsx` | terminal-win32.ts: added a `win32RasterFontConsole()` export (kernel32 `GetCurrentConsoleFontEx` via the existing dlopen table): true only when stdout is a win32 TTY, none of `WT_SESSION`/`TERM_PROGRAM`/`TERM` are set (conhost sets none of them; Windows Terminal, VS Code, mintty, ssh each set one), and the current console font lacks `TMPF_TRUETYPE`. app.tsx: an `onMount` next to the other startup toasts shows a one-shot warning toast when it returns true, telling the user to use Windows Terminal or a TrueType console font. | Legacy conhost with a raster font can only draw OEM-code-page characters: the display path best-fit converts everything else to "?" at draw time (chcp/SetConsoleOutputCP docs), so Vietnamese/CJK users see corrupted prompts and responses even though the buffer and the data are correct. The app cannot fix the rendering, so it warns instead of corrupting silently. | Keep the `win32RasterFontConsole` export and the warning-toast `onMount`; take upstream's surrounding changes. Drop both if upstream ships its own legacy-console/raster-font warning. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them); compiled binary emitted as `bin/codev` (was `bin/opencode`; smoke-test path follows); win32 targets additionally stage ripgrep at `bin/rg.exe` (`RG_VERSION` — `"latest"` resolves the newest GitHub release per build via the releases API, an exact version pins its release tag; asset zip extracted in-memory with `@zip.js/zip.js` so no unzip/powershell host dependency, magic-byte-checked against proxy block pages; one release lookup, and x64/x64-baseline share one download per arch) | npm publishing under the fork's names; the binary inside platform packages carries the fork's name (postinstall.mjs copies it into the `codev` bin shim). The `Script.release` upload block stays upstream-shaped and unused — the fork's `.github/workflows/release.yml` packages and uploads GitHub-release artifacts itself. Bundling rg makes a bare `npm i -g codev-code` self-sufficient on Windows, where fff is disabled so file search depends entirely on rg and corporate networks block the runtime GitHub download (see the Windows file-search row for the resolution side). `RG_VERSION` defaults to `"latest"`: the build machine needs GitHub access anyway, and floating leaves no version to keep in sync — binary.ts's pinned `VERSION` governs only the runtime download fallback (the mirror hosts just that version) and is independent of the bundled copy. | Keep our `name:` line, `bin/codev` paths, and the ripgrep bundling block; take upstream's surrounding changes. | -| `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; published-name→dir map; main pkg name + bin `codev` → `./bin/codev.exe` (postinstall stub file renamed from `${pkg.name}.exe`, matching the renamed platform binary); embedded README; no `repository` field in the generated package.json (npm renders it as the Repository link and derives a `#readme` Homepage from it — both kept off the npm page); env-guarded exit before the Docker/AUR/Homebrew section; `npm publish` retried up to 5 times with linear backoff, treating a version that turns up on the registry after a failed attempt as published; stale `*.tgz` deleted before `bun pm pack`; the dist manifest scan skips the generated main package directory | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra; the npm page should not link the fork's GitHub repo. Releases run from a laptop rather than CI, where the 13 concurrent 40-60MB uploads regularly time one package out (`npm error FETCH_ERROR`) and abort the release mid-way; resuming such a release must not make the main package depend on itself. `bun pm pack` writes the tarball into the directory it packs, so the retry path it enables would otherwise bundle the previous run's tarball into the next one, doubling every package (~55MB → ~112MB) and making the timeouts far worse. | Keep our names, description, bin mapping, omitted `repository`, retry loop + early exit; take upstream's npm-section changes above the exit. If upstream adds its own publish retry, drop ours for theirs. | +| `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; published-name→dir map; main pkg name + bin `codev` → `./bin/codev.exe` (postinstall stub file renamed from `${pkg.name}.exe`, matching the renamed platform binary); embedded README; no `repository` field in the generated package.json (npm renders it as the Repository link and derives a `#readme` Homepage from it — both kept off the npm page); env-guarded exit before the Docker/AUR/Homebrew section; `npm publish` retried up to 5 times with linear backoff, treating a version that turns up on the registry after a failed attempt as published; stale `*.tgz` deleted before `bun pm pack`; the dist manifest scan skips the generated main package directory ; the stub's message gains two lines pointing npm 12+ users at `npm install -g codev-code --allow-scripts=codev-code`, since npm 12 blocks install scripts on global installs by default and the stub is exactly what they then run | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra; the npm page should not link the fork's GitHub repo. Releases run from a laptop rather than CI, where the 13 concurrent 40-60MB uploads regularly time one package out (`npm error FETCH_ERROR`) and abort the release mid-way; resuming such a release must not make the main package depend on itself. `bun pm pack` writes the tarball into the directory it packs, so the retry path it enables would otherwise bundle the previous run's tarball into the next one, doubling every package (~55MB → ~112MB) and making the timeouts far worse. | Keep our names, description, bin mapping, omitted `repository`, retry loop + early exit; take upstream's npm-section changes above the exit. If upstream adds its own publish retry, drop ours for theirs. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | -| `packages/opencode/script/postinstall.mjs` | `base` prefix `opencode-` → `codev-code-`; source/target binary names `opencode`/`opencode.exe` → `codev`/`codev.exe` (matches build.ts's `bin/codev` and publish.ts's `bin/codev.exe` stub); temp-dir prefix and failure message rebranded; best-effort `copyRipgrep` copies the Windows packages' bundled `bin/rg.exe` next to the codev binary, on both resolution paths (in the temp npm-install path before its `finally` cleanup deletes the package) | Install-time platform-binary fetch must match the published package and binary names. The rg copy is what puts the bundled binary where core's ripgrep resolution looks (`path.dirname(process.execPath)`); it must never fail the install — the agent still has PATH/cache/download resolution without it. | Keep our prefix, binary names, and the `copyRipgrep` calls. | +| `packages/opencode/script/postinstall.mjs` | `base` prefix `opencode-` → `codev-code-`; source/target binary names `opencode`/`opencode.exe` → `codev`/`codev.exe` (matches build.ts's `bin/codev` and publish.ts's `bin/codev.exe` stub); temp-dir prefix and failure message rebranded; best-effort `copyRipgrep` copies the Windows packages' bundled `bin/rg.exe` next to the codev binary, on both resolution paths (in the temp npm-install path before its `finally` cleanup deletes the package); `installPackage` spawns the fallback `npm install` through `cmd.exe /d /s /c npm …` on Windows (plain `npm` elsewhere) and takes a `failures` array; `verifyBinary` returns `null` or a one-line reason (spawn error, exit code/signal, first stderr line — stderr is piped, not ignored); `main` collects a per-package reason for every failed step (not installed → fallback fetch → `--version`) and appends a `What was tried:` list to the final error; `firstLine` helper | Install-time platform-binary fetch must match the published package and binary names. The rg copy is what puts the bundled binary where core's ripgrep resolution looks (`path.dirname(process.execPath)`); it must never fail the install — the agent still has PATH/cache/download resolution without it. | Keep our prefix, binary names, the `copyRipgrep` calls, the cmd.exe spawn, and the failure reporting; take upstream's surrounding changes. The cmd.exe spawn fixes an upstream bug (Node ≥ 18.20.2/20.12.2 refuses to spawn `npm.cmd` without a shell, so the fallback silently never ran on Windows) — drop it if upstream ships its own fix. | | `packages/tui/src/logo.ts` | Pixel glyphs "open"+"code" → "co"+"dev"; added a `brand` export (`navy` `#19224c` / `red` `#ee0033`, the landing page's `--color-brand-navy`/`--color-brand-red`), a `wordmarkHalves` export (each row's "co" and "dev" halves with the shadow marks substituted to glyphs, so each half can take its own brand color), the composed `wordmark` (plain lines, built from `wordmarkHalves`) and `ansiWordmark` (bold, no shadow: "co" in the terminal's default foreground, "dev" in brand red truecolor) exports for CLI banners; added a `badge` export (the "c" of the wordmark — `logo.left` rows 1-3 sliced to the first glyph, with the `_` shadow-fill marks stripped to spaces so it renders as a flat outline) for the `--mini` splashes | Branded wordmark art; `ansiWordmark` feeds the CLI help banner and session epilogue. | Keep our glyphs + exports; take upstream's surrounding changes. | | `packages/tui/src/component/logo.tsx` | Renders the lowercase codev wordmark from `wordmarkHalves` — one bold `` per half, no per-char shadow cells — with "co" in `brand.navy` on light / `theme.text` on dark and "dev" in `brand.red`, instead of upstream's two-tone "open"+"code" | Banner is pixel-identical to the landing page wordmark (`codev-landing-page` `app/components/PixelWordmark.tsx`: `text-brand-navy dark:text-foreground` + `text-brand-red`), so the marketing site and the TUI read as one brand. Deliberately fixed brand colors rather than the theme's `secondary`, and no drop shadow — the landing wordmark has neither. | Keep our version wholesale. If upstream restructures the component, re-apply the two-half brand coloring rather than reverting to a theme color. | | `packages/ui/src/components/logo.tsx`, `packages/ui/src/components/logo.css` | `Logo` renders the lowercase "codev" wordmark instead of upstream's "opencode": the `wordmarkHalves` pixel glyphs of `packages/tui/src/logo.ts` resolved to a 24x6 grid at one unit per pixel (viewBox `0 0 24 6`, was `0 0 234 42`), as two ``s — `data-slot="logo-wordmark-co"` and `…-dev` — with no drop-shadow paths. Colors come from two custom properties defined in logo.css on `[data-component="logo-wordmark"]`: `--logo-wordmark-dev` is always brand red `#ee0033`, `--logo-wordmark-co` is brand navy `#19224c` on light and `var(--icon-strong-base)` on dark. The dark swap is declared three times — under `prefers-color-scheme: dark` for surfaces that never stamp a theme, then under both `[data-color-scheme="light"]` and `[data-color-scheme="dark"]` so the app's explicit choice (`packages/ui/src/theme/context.tsx` sets it on ``) beats the OS preference in both directions. Brand hexes are literals, not theme tokens — same reasoning as the TUI component row. `Mark` and `Splash` likewise drop upstream's square "o" for the codev "C": the block art `█▀▀▀ / █ / ▀▀▀▀` on a 4x5 grid with a one-unit stroke — the same geometry `packages/desktop/icons/icon.svg` and the `codev --mini` badge (`badge` in `packages/tui/src/logo.ts`) draw — as one full-bleed `` each, `data-slot="logo-mark-c"` at viewBox `0 0 16 20` and `logo-splash-c` at `0 0 80 100`. Upstream's second path on each glyph, which shaded the lower half of the "o"'s enclosed counter in `--icon-weak-base` / `--icon-base`, is dropped: the C is open on the right so there is no counter to shade, and the TUI badge is a flat outline for the same reason. The two split on color. `Splash` is brand-locked through `--logo-splash: #ee0033`, declared once on `[data-component="logo-splash"]` with no dark-mode swap — red clears 4:1 against both the light and dark `background-base`, unlike the wordmark's navy half. `Mark` keeps `var(--icon-strong-base)` because two of its three call sites are `opacity-10` watermarks in the session side panel, where brand red reads as a pink wash instead of a neutral texture. | The web/desktop banner is the same art the TUI home banner and the landing page wordmark draw, so every surface reads as one brand. Same for the marks: the desktop boot splash, the app icon, the landing page icon and the `--mini` badge are one glyph in one red, so the splash should not be the only surface still drawing upstream's "o" in theme grey. | Keep our version wholesale. If upstream restructures the component, re-apply the two-half brand coloring rather than reverting to `--icon-*` tokens, and re-apply the C rather than taking back the "o". Keep `Splash` brand-locked and `Mark` tokenized — do not "unify" them, the split is load-bearing for the `opacity-10` watermarks. | diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index cf3030488a..0e0d55412e 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -125,23 +125,52 @@ function resolveBinary(name) { return binaryPath } -function installPackage(name) { +// Fork: records why each step failed in `failures` so the final error says +// what actually went wrong (the package was never installed, npm could not +// start, the binary would not run) instead of only naming packages to retry. +function installPackage(name, failures) { const version = packageJson.optionalDependencies?.[name] - if (!version) return + if (!version) { + failures.push(`${name}: not pinned in optionalDependencies, nothing to fetch`) + return + } const temp = fs.mkdtempSync(path.join(os.tmpdir(), "codev-install-")) try { - const result = childProcess.spawnSync( - "npm", - ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`], - { stdio: "inherit", windowsHide: true }, - ) - if (result.status !== 0) return + const args = [ + "install", + "--ignore-scripts", + "--no-save", + "--loglevel=error", + "--prefix", + temp, + `${name}@${version}`, + ] + // Fork: `npm` is `npm.cmd` on Windows, and Node refuses to spawn .cmd + // files without a shell (EINVAL since 18.20.2/20.12.2, CVE-2024-27980), + // so this fallback silently never ran there. Go through cmd.exe as the + // Node docs recommend; Node still quotes each argument, so a temp path + // with spaces survives. + const result = + platform === "windows" + ? childProcess.spawnSync("cmd.exe", ["/d", "/s", "/c", "npm", ...args], { stdio: "inherit", windowsHide: true }) + : childProcess.spawnSync("npm", args, { stdio: "inherit", windowsHide: true }) + if (result.error) { + failures.push(`${name}: could not start npm to fetch it (${result.error.message})`) + return + } + if (result.status !== 0) { + failures.push(`${name}: npm install ${name}@${version} exited with ${result.status ?? `signal ${result.signal}`}`) + return + } const packageDir = path.join(temp, "node_modules", name) copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary) // Before the finally deletes temp — the bundled rg lives in the same package. copyRipgrep(packageDir) return true + } catch (error) { + failures.push(`${name}: fetched, but ${firstLine(error.message)}`) + return } finally { fs.rmSync(temp, { recursive: true, force: true }) } @@ -176,33 +205,56 @@ function copyBinary(source, target) { fs.chmodSync(target, 0o755) } +// Fork: returns null when the binary runs, else a one-line reason. stderr is +// captured (not ignored) because a binary that a security policy or +// antivirus refuses to launch, or one missing a DLL, says so there and +// nowhere else. function verifyBinary() { const result = childProcess.spawnSync(targetBinary, ["--version"], { encoding: "utf8", - stdio: "ignore", + stdio: ["ignore", "ignore", "pipe"], windowsHide: true, }) - return result.status === 0 + if (result.error) return `${targetBinary} could not be started (${result.error.message})` + if (result.status === 0) return null + const stderr = (result.stderr || "").trim() + const exit = result.status === null ? `was killed by signal ${result.signal}` : `exited with code ${result.status}` + return `${targetBinary} --version ${exit}${stderr ? `: ${firstLine(stderr)}` : ""}` +} + +function firstLine(text) { + return String(text).split(/\r?\n/, 1)[0].slice(0, 300) } function main() { + const failures = [] for (const name of packageNames()) { + let binary try { - const binary = resolveBinary(name) + binary = resolveBinary(name) copyBinary(binary, targetBinary) - if (verifyBinary()) { - copyRipgrep(path.dirname(path.dirname(binary))) - return + } catch (error) { + failures.push(`${name}: not installed by your package manager (${firstLine(error.message)})`) + // Fetch it ourselves; the fallback verifies what it copied. + if (installPackage(name, failures)) { + const problem = verifyBinary() + if (!problem) return + failures.push(`${name}: fetched, but ${problem}`) } - } catch { - if (installPackage(name) && verifyBinary()) return + continue + } + const problem = verifyBinary() + if (!problem) { + copyRipgrep(path.dirname(path.dirname(binary))) + return } + failures.push(`${name}: installed, but ${problem}`) } throw new Error( `It seems your package manager failed to install the right codev CLI package. Try manually installing ${packageNames() .map((name) => JSON.stringify(name)) - .join(" or ")}.`, + .join(" or ")}.\n\nWhat was tried:\n${failures.map((line) => ` - ${line}`).join("\n")}`, ) } diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 48f073e94c..e4b66bbe9c 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -95,6 +95,8 @@ await Bun.file(`./dist/${pkg.name}/bin/codev.exe`).write( 'echo "" >&2', 'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2', 'echo "package manager like pnpm that does not run postinstall scripts by default." >&2', + 'echo "npm 12 and later block install scripts on global installs unless allowed:" >&2', + `echo " npm install -g ${PUBLISH_NAME} --allow-scripts=${PUBLISH_NAME}" >&2`, 'echo "" >&2', 'echo "To fix this, run the postinstall script manually:" >&2', `echo " cd node_modules/${PUBLISH_NAME} && node postinstall.mjs" >&2`, From 2f0fd0739f0fbfff8d170510bcca15bb8724db0c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 15:49:25 +0700 Subject: [PATCH 2/2] docs: link the postinstall changelog entry to #355 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b1437530..81f49edeb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1424,6 +1424,7 @@ not found`. Tabs belonging to a removed server are now closed before its Windows it had never actually run. Installs on npm 12, which blocks install scripts on global installs by default, are told the `--allow-scripts` flag to use when the un-set-up placeholder is run. + ([#355](https://github.com/quickbeard/codev-code/pull/355)) ## 1.18.4-8