CodeNomad/scripts/test-opencode-legacy-replacement-native.mjs
Pascal André d36f8a703b
fix(opencode-setup): offer OpenCode 2 when an old or foreign OpenCode blocks setup (#982)
# Offer OpenCode 2 when an old or foreign OpenCode blocks setup

## Problem

CodeNomad prefers the `opencode` found on PATH and only installs or
updates OpenCode through a verified `@opencode/cli` npm installation.
When PATH held OpenCode 1.x — from the npm package `opencode-ai`, or
from winget's `SST.opencode` (still 1.19.0) — setup reported no active
OpenCode service and offered no action at all, only "update manually".
For npm users this was a real dead end: `opencode-ai` and
`@opencode/cli` both claim the `opencode` command, so npm refuses to
install OpenCode 2 over it (EEXIST).

## Expected behavior

**OpenCode 1.x installed with npm.** Recognized only from the package's
`package.json` and the shim's actual target, never from a version
string. Setup offers **Replace OpenCode 1.x with OpenCode 2** behind a
confirmation. The confirmation states that the data folder is kept and
that OpenCode 2 imports 1.x conversations itself; CodeNomad does not
convert them. On confirm, under the existing installation lock,
`opencode-ai` is removed from that npm prefix and the existing shared
installation path installs and verifies `@opencode/cli`, registers PATH
and implies no daemon restart. Consent carries the exact confirmed
installation (`path`, `version`); the route validates it, the service
compares it with current status and the installer re-checks it under the
lock, refusing any mismatch. A broken 1.x install whose `--version`
fails can still be replaced. Before uninstalling on Windows, every
native executable owned by the legacy package (including nested
`opencode-<os>-<arch>` packages used by 1.0–1.15.0) is checked for use.
npm failures map to "in use", "1.x still installed" or "partially
removed" (with manual instructions) from npm's error code only. If
installation fails after removal, the panel reports it and offers a
plain install as retry; nothing is retried automatically or by another
mechanism.

**Foreign installations (winget, Homebrew, curl, manual).** Never
modified. The foreign version probe is asynchronous, bounded to 5 s and
kills its process tree on timeout; an unknown result keeps the PATH
command's priority and expires after 60 s, the setup panel's Check
action forces a fresh probe, only one probe per command runs at a time,
and successful results are cached by path, mtime and size. When the PATH
executable is unusable or below the minimum (the notice names the
minimum version), setup offers **Install OpenCode 2 for CodeNomad** into
the user npm prefix, and CodeNomad then uses that managed copy. The
panel says the older copy is untouched and that the terminal `opencode`
may still start it. When the resolved winget package folder proves the
`SST.opencode` ID, it adds that `winget uninstall SST.opencode` is
optional (text only, never executed). A working foreign OpenCode 2 keeps
today's PATH priority.

**Mixed case.** With winget's copy first on PATH and an npm 1.x behind
it, only the npm package is replaced, with confirmation.

**Custom executable** chosen in settings is never overridden or
installed over.

## Verified facts

- npm bins: `opencode-ai` 1.18.10 / 1.19.2 → `opencode`; `@opencode/cli`
2.0.30 → `opencode`, `opencode2`.
- OpenCode 1.x and 2.x share `<data>/opencode/opencode.db`; the 2.x
server runs a V1 migration layer copying 1.x sessions (checked in
upstream tags v2.0.0–2.0.15).
- winget `SST.opencode` is a portable zip; user scope links
`%LOCALAPPDATA%\Microsoft\WinGet\Links\opencode.exe` to
`…\WinGet\Packages\SST.opencode_<source>\opencode.exe`; machine scope
uses `%ProgramFiles%\WinGet\…`.

## Known limitations

- Machine-wide winget installs and a customized winget Packages root are
not covered by tests; an old executable there still gets the
install-alongside action, without the winget label.
- The 1.x → 2.x conversation import is upstream behavior, verified from
source rather than on a real history.
- POSIX symlink layouts and the POSIX process-group kill are covered by
tests that run only on Linux/macOS.
- A real running 1.x process holding its executable was simulated with a
read-only file; npm's mid-uninstall EPERM/EBUSY parsing is tested with
synthetic stderr.
- For up to 5 s after startup, before the first foreign probe completes,
a launch keeps using the PATH command.

## Validation

- Server: 93 pass in touched areas (2 pre-existing POSIX-only skips);
full suite 2341 pass, with 2 failures in untouched files (`meta.test.ts`
OS-name expectation on the test machine; `binary-validation.test.ts`
timing, passes alone). Coverage includes legacy shim recognition and
rejections, consent, remove-then-install under lock, failure and retry,
winget symlink/hardlink layouts with PATH order, winget + npm 1.x,
working vs unusable foreign installs, custom executable, status/upgrade
logic and route errors.
- Browser: 33/33 setup tests, including replace
(cancel/confirm/failure/retry), broken 1.x, the winget notice and an
outdated foreign 2.x.
- Real npm on Windows:
`scripts/test-opencode-legacy-replacement-native.mjs` installs published
`opencode-ai@1.18.10` and `1.15.0` in temporary prefixes, reproduces
EEXIST, finds the nested native executables, refuses mismatched consent,
then performs the confirmed replacement to 2.0.29 without starting a
service.
- Server and UI typechecks clean; new strings in all 10 locales.
2026-10-16 15:44:17 +02:00

69 lines
5.2 KiB
JavaScript

// Run with: node --import tsx scripts/test-opencode-legacy-replacement-native.mjs
// Real npm and published packages in an isolated home/prefix/cache. Never touches
// the user's npm prefix, PATH, OpenCode installation or shared daemon.
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
import { promisify } from "node:util"
import os from "node:os"
import path from "node:path"
import { installSharedOpenCode, npmCommandDirectory, npmExecutable, resolveDefaultInstallation, sharedInstallationPlan } from "../packages/server/src/opencode-update/shared-installation.ts"
import { legacyNativeImages, legacyNpmPackageRoot } from "../packages/server/src/opencode-update/legacy-installation.ts"
import { bundledNpm } from "../packages/server/src/opencode-update/npm-runtime.ts"
import { RECOMMENDED_OPENCODE_VERSION } from "../packages/server/src/opencode/runtime-support.ts"
const LEGACY_VERSION = process.env.CODENOMAD_FIXTURE_LEGACY_VERSION || "1.18.10"
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-legacy-replacement-"))
const node = process.env.CODENOMAD_FIXTURE_NODE || process.execPath
const npm = bundledNpm(node)
assert.ok(npm, "npm must be bundled beside the selected Node")
const prefix = path.join(root, "npm prefix")
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) =>
!/^(OPENCODE_|CODENOMAD_|CLI_|XDG_|npm_|PATH$|NODE_|ELECTRON_|WEBVIEW2_)/i.test(key)))
Object.assign(env, {
HOME: root, USERPROFILE: root, APPDATA: path.join(root, "AppData"), LOCALAPPDATA: path.join(root, "LocalAppData"),
XDG_CONFIG_HOME: path.join(root, "config"), XDG_DATA_HOME: path.join(root, "data"), XDG_STATE_HOME: path.join(root, "state"),
XDG_CACHE_HOME: path.join(root, "cache"), npm_config_prefix: prefix, npm_config_cache: path.join(root, "npm-cache"),
npm_config_userconfig: path.join(root, "npmrc"),
// The user's PC: the npm prefix holding OpenCode 1.x is on PATH.
PATH: [npmCommandDirectory(prefix), path.dirname(node), ...(process.platform === "win32" ? [`${process.env.SystemRoot}/System32`] : ["/usr/bin", "/bin"])].join(path.delimiter),
})
for (const key of ["APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) await mkdir(env[key], { recursive: true })
const run = (args) => promisify(execFile)(node, [npm, ...args], { cwd: root, env, timeout: 300_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true })
const options = { home: root, env, node, npm, registerPath: async () => {} }
const result = { root, legacyVersion: LEGACY_VERSION, target: RECOMMENDED_OPENCODE_VERSION }
try {
await run(["install", "--global", "--prefix", prefix, "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org", `opencode-ai@${LEGACY_VERSION}`])
const plan = sharedInstallationPlan(options)
assert.equal(plan.prefix, prefix)
assert.equal(plan.legacy?.version, LEGACY_VERSION, "the published 1.x shim is recognized from its manifest and target")
result.legacyCommand = plan.legacy.command
// Fact check: npm refuses to link @opencode/cli's `opencode` bin over the 1.x shim.
const collision = await run(["install", "--global", "--prefix", prefix, "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org",
`@opencode/cli@${RECOMMENDED_OPENCODE_VERSION}`]).then(() => undefined, error => `${error.stderr ?? error.message}`)
assert.ok(collision && /EEXIST/.test(collision), "plain npm installation collides with the 1.x shim")
result.collision = collision.split(/\r?\n/).find(line => /EEXIST/.test(line))
assert.equal(sharedInstallationPlan(options).legacy?.version, LEGACY_VERSION, "the failed plain install left 1.x in place")
await assert.rejects(installSharedOpenCode(RECOMMENDED_OPENCODE_VERSION, options), /explicit confirmation/)
assert.ok(existsSync(legacyNpmPackageRoot(prefix, process.platform)))
result.nativeImages = legacyNativeImages(legacyNpmPackageRoot(prefix, process.platform)).map(image => path.relative(prefix, image))
assert.ok(result.nativeImages.some(image => /\.exe$|[\\/]\.opencode$/.test(image)), "the package's native executable is preflighted")
const consent = { path: plan.legacy.command, version: LEGACY_VERSION }
await assert.rejects(installSharedOpenCode(RECOMMENDED_OPENCODE_VERSION, { ...options, replaceLegacy: { ...consent, version: "1.0.0" } }), /explicit confirmation/)
assert.equal(await installSharedOpenCode(RECOMMENDED_OPENCODE_VERSION, { ...options, replaceLegacy: consent }), npmExecutable(prefix))
assert.equal(existsSync(legacyNpmPackageRoot(prefix, process.platform)), false)
assert.deepEqual(sharedInstallationPlan(options), { prefix })
const resolved = resolveDefaultInstallation(options)
assert.equal(resolved.source, "path")
const version = (await promisify(execFile)(npmExecutable(prefix), ["--version"], { cwd: root, env, timeout: 60_000, windowsHide: true })).stdout.trim()
assert.equal(version.replace(/^opencode2? v?/, ""), RECOMMENDED_OPENCODE_VERSION)
result.resolved = resolved
result.success = true
} finally {
await writeFile(path.join(root, "result.json"), JSON.stringify(result, null, 2))
console.log(JSON.stringify(result, null, 2))
}