mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
# 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.
69 lines
5.2 KiB
JavaScript
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))
|
|
}
|