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.
This commit is contained in:
Pascal André 2026-10-16 15:44:17 +02:00 • committed by GitHub
parent e41bf9401b
commit d36f8a703b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1584 additions and 87 deletions

View file

@ -139,7 +139,7 @@
- Git is a full-functionality prerequisite, with directory-only degraded conversations when the backend cannot find Git. Only the explicitly opened physical folder is session authority in that mode; never infer sibling worktrees from native project IDs. Keep ancestor/descendant mutation identities covered by the deletion fence across Git availability changes. Inform agents through the owned native `codenomad.git-availability` instruction before prompts/custom commands, remove stale context after recovery, and keep this advisory separate from fail-closed environment synchronization. No blocking Git setup UI. Validate with `scripts/test-git-degraded-native.mjs` using an isolated CLI/database and provider.
- OpenCode minimum requirements must follow demonstrated technical dependencies, never the latest published or solely tested version. Keep required, recommended/tested and unverified versions distinct in `opencode/runtime-support.ts` and setup diagnostics. Validate authenticated daemon metadata/contract before client use/plugin provisioning. Setup uses bundled Node/npm for a shared user npm installation and prefers PATH. The retired private `~/.local/share/codenomad/opencode` tree must never be discovered or launched; ignore its receipts and saved selections without migrating or deleting it. Keep installer locking and Windows live-executable preflight in `opencode-update/installation-lock.ts`; register terminal PATH only on explicit installation. A running daemon restart is a separate explicit action. Configuration reload is also explicit: native `location.reload` rebuilds every loaded location and cancels pending Forms/permissions, so never use it as an automatic watcher fallback. Retire old wire translations without removing current identity/ownership checks. See `dev-docs/OPENCODE_V2_POST_BETA.md` for version boundaries and isolated validation evidence.
- OpenCode minimum requirements must follow demonstrated technical dependencies, never the latest published or solely tested version. Keep required, recommended/tested and unverified versions distinct in `opencode/runtime-support.ts` and setup diagnostics. Validate authenticated daemon metadata/contract before client use/plugin provisioning. Setup uses bundled Node/npm for a shared user npm installation and prefers PATH. Only a verified `@opencode/cli` npm installation is updated via npm; an OpenCode 1.x `opencode-ai` npm shim (identified by its manifest and shim target, never a version string) is replaced in its prefix only after explicit UI confirmation naming that exact installation (rechecked under the installation lock), with every native image of the package preflighted, npm failure states reported truthfully and failure after removal retryable rather than replayed. Foreign `--version` probes are asynchronous, bounded and tree-killed (`opencode-update/foreign-probe.ts`); unknown keeps PATH precedence and synchronous resolution never spawns. Foreign installations (winget, Homebrew, curl, manual) are never modified: when below the minimum or unusable, OpenCode 2 is installed in the user npm prefix beside them and the verified managed installation wins over that PATH command; valid foreign V2 and explicit custom executables keep their precedence. Detection lives in `opencode-update/legacy-installation.ts`. The retired private `~/.local/share/codenomad/opencode` tree must never be discovered or launched; ignore its receipts and saved selections without migrating or deleting it. Keep installer locking and Windows live-executable preflight in `opencode-update/installation-lock.ts`; register terminal PATH only on explicit installation. A running daemon restart is a separate explicit action. Configuration reload is also explicit: native `location.reload` rebuilds every loaded location and cancels pending Forms/permissions, so never use it as an automatic watcher fallback. Retire old wire translations without removing current identity/ownership checks. See `dev-docs/OPENCODE_V2_POST_BETA.md` for version boundaries and isolated validation evidence.
- A selected CLI's `service status` can report `stopped` for a live older daemon. Preserve the bounded, read-only registration fallback in `workspaces/native-service-registration.ts` and authenticate historical metadata before allowing start; `ensure()` repeats discovery. Native service lookup is separate from plugin-root discovery, which must still use the connected daemon's `config.get`. Never write service registration/configuration files from the backend.
- Provider usage providers receive credentials explicitly: the route projects each integration's active native `credential.list` entry through `usage/native-credentials.ts` (legacy auth.json only on a missing endpoint) and caches snapshots per provider credential identity. Codex keeps its selected-connection adapter. Claude quotas follow the session's login (`usage/claude-credential.ts`): `anthropic`/`claude` use only OpenCode's Anthropic OAuth, opencode-claude's `claude-code` only the Claude Code login the route resolves on the daemon host (session environment, WSL path translation, Claude Code's token/Keychain/file order) and injects as the `claude-code` entry, so it joins the cache identity; never cross-fall back between them. Never exchange refresh tokens or embed OAuth client secrets; Google stays unsupported for that reason. Model-scoped limits add `window:Model` rows beside the plan windows.

View file

@ -108,6 +108,71 @@ saved private selections resolve through current discovery, and validation/launc
reject that tree. No migration or automatic deletion is performed. Native user data
under `~/.local/share/opencode` is unrelated and remains owned by OpenCode.
### Older OpenCode installations
Only a verified `@opencode/cli` npm installation is updated via npm. OpenCode 1.x
is published as `opencode-ai`; both packages declare an `opencode` bin, and npm's
bin-links refuses (EEXIST) to link one package's shim over another's without
`--force`, so installing 2.x beside a 1.x shim in the same prefix cannot succeed.
CodeNomad recognizes the 1.x shim only by the package manifest (`opencode-ai`,
version below the minimum) and the shim's actual target. The setup panel then
offers an explicit "Replace OpenCode 1.x with OpenCode 2" action, also when the
1.x executable cannot run (for example the published placeholder left when its
postinstall did not run). The confirmation sends the exact installation shown
(command path and package version); the server rechecks it under the prefix lock
and refuses a mismatch. Bundled npm then uninstalls `opencode-ai` from that prefix
and the ordinary installation path installs and verifies `@opencode/cli`.
The Windows running-image preflight covers every native file of the package:
`bin/` and the nested `node_modules/opencode-<os>-<arch>[-variant]/bin/`. 1.0.0-1.15.0
declare a Node launcher bin (`bin/opencode`) that runs the nested platform
package's executable (or a `bin/.opencode` copy); 1.15.1+ declare `bin/opencode.exe`,
which postinstall links or copies from the nested package. If npm's uninstall
fails anyway, its symbolic error code (only) classifies the outcome: with the
package still intact, EPERM/EBUSY/EACCES report `installation_in_use`, other
codes a generic failure stating 1.x is still installed; a package npm already
changed reports `legacy_partially_removed` with manual `npm uninstall -g
opencode-ai` guidance. A failure after complete removal is reported as
`legacy_removed_install_failed`; the next attempt is an ordinary installation,
never an automatic replay. The data directory is untouched: OpenCode
2 uses the same `opencode.db` and its server imports 1.x sessions itself at
startup (upstream `V1Migration`, present since 2.0.0); CodeNomad converts nothing.
Foreign installations (winget, Homebrew, curl, manual) are never modified. When
the PATH command cannot run or reports a comparable version below the minimum,
OpenCode 2 is installed in the user npm prefix beside it, and resolution prefers
that verified installation (`installationSource: "managed"`) over the PATH
command. The UI says the terminal `opencode` may still start the older version.
winget portable packages are identified from default locations only:
`%LOCALAPPDATA%\Microsoft\WinGet\Links` or `%ProgramFiles%\WinGet\Links`
(symlink, or hardlink/copy fallback), or a target under a default `WinGet\Packages`
root. The package ID (for example `SST.opencode`) is shown with an optional manual
`winget uninstall` hint only when the resolved
`<PackageIdentifier>_Microsoft.Winget.Source_8wekyb3d8bbwe` directory proves it.
A valid foreign OpenCode 2 or custom-labelled build keeps PATH precedence.
Foreign `--version` probes never run synchronously. `opencode-update/foreign-probe.ts`
spawns them asynchronously with a 5 s deadline and kills the whole process tree on
timeout or output overflow. Results are cached by the command's file identity
(path, mtime and size, plus the executable an npm-style shim launches); any change
reads as unknown until reprobed. A timeout is cached as unknown for only 60 s, so
a hanging wrapper is not re-run on every read while a slow first run (for example
an antivirus scan) is retried later. At most one probe of a command runs at a
time; concurrent readers, including an explicit recheck, join it. The setup
panel's Check action sends `recheck=1`, which forces a fresh probe even within
that delay; automatic reads never do. Unknown never supersedes the PATH command.
Synchronous resolution (workspace launch, binary validation) only reads the cache
and starts a background probe on a miss or an expired unknown; status reads and
installation await the bounded probe first, and the backend warms it at startup.
`node --import tsx scripts/test-opencode-legacy-replacement-native.mjs` installs the
published `opencode-ai` (default 1.18.10, `CODENOMAD_FIXTURE_LEGACY_VERSION`) into
an isolated prefix/cache/home, checks that plain npm installation of
`@opencode/cli` fails with EEXIST and leaves 1.x in place, checks that a stale
consent is refused and that the native images are found, then performs the
confirmed replacement and verifies the installed 2.x command. It passed on Windows
for 1.18.10 and for the 1.15.0 Node-launcher layout. It never runs a service.
An exclusive `.codenomad-opencode-install.lock` in the npm prefix serializes
CodeNomad backends; the version is re-probed under that lock. A competing backend
gets a retryable conflict. A lock left after a crash is deliberately not stolen

View file

@ -617,8 +617,13 @@ export interface OpenCodeUpdateStatus {
incompatibilityReason?: "plugin_runtime" | "step_timestamp" | "canonical_api" | "session_environment"
state: "missing" | "update_required" | "ready" | "error"
binaryPath: string
installationSource?: "path" | "user"
/** `managed`: the user npm OpenCode 2 is preferred over an older PATH command. */
installationSource?: "path" | "user" | "managed"
needsSharedInstallation?: boolean
/** OpenCode 1.x npm package that the install action replaces after explicit confirmation. */
legacyReplacement?: { path: string; version: string }
/** Older or unusable OpenCode found on PATH; CodeNomad never modifies it. */
foreignInstallation?: { path: string; version?: string; source: "winget" | "other"; packageId?: string }
daemonVersion?: string
serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "incompatible" | "error"
canReload?: boolean

View file

@ -14,6 +14,7 @@ import { resolveConfigLocation } from "./config/location"
import { TemporaryFolderRegistry } from "./workspaces/temporary-workspaces"
import { SettingsService } from "./settings/service"
import { BinaryResolver } from "./settings/binaries"
import { prepareForeignProbe } from "./opencode-update/shared-installation"
import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
@ -389,6 +390,9 @@ async function main() {
const settings = new SettingsService(configLocation, eventBus, configLogger)
const binaryResolver = new BinaryResolver(settings)
// Warm the bounded, asynchronous version probe of a foreign PATH OpenCode so
// synchronous resolution can prefer a managed OpenCode 2 without spawning.
void prepareForeignProbe().catch(() => {})
const pruningLifecycle = new PruningLifecycle()
const nativeParent = new NativeParent()
const automationLifecycle = new DesktopPluginLifecycle("automation")

View file

@ -0,0 +1,119 @@
import { execFile, spawn, type ChildProcess } from "node:child_process"
import { realpathSync, statSync } from "node:fs"
import { buildSpawnSpec, parseBinaryVersion } from "../workspaces/spawn"
/** `undefined` means unknown (timed out or not probed yet): the PATH command keeps precedence. */
export type ForeignProbe = { valid: boolean; version?: string } | undefined
export const FOREIGN_PROBE_TIMEOUT_MS = 5_000
/** A timed-out probe is retried after this delay (for example once a first-run
* antivirus scan has finished); definite results last until the file changes. */
export const FOREIGN_PROBE_UNKNOWN_TTL_MS = 60_000
const OUTPUT_LIMIT = 64 * 1024
export interface ForeignProbeOptions {
timeoutMs?: number
/** Explicit user recheck: ignore cached results, but still join a running probe. */
force?: boolean
unknownTtlMs?: number
}
const results = new Map<string, { key: string; result: ForeignProbe; expiresAt?: number }>()
const pending = new Map<string, { key: string; promise: Promise<ForeignProbe> }>()
function cachedResult(command: string, key: string) {
const cached = results.get(command)
return cached?.key === key && (cached.expiresAt === undefined || Date.now() < cached.expiresAt) ? cached : undefined
}
/** File identity of the command and, for npm-style shims, of the executable it
* launches. Any change invalidates a cached decision. */
function identity(command: string): string | undefined {
try {
const files = [realpathSync(command)]
const spec = buildSpawnSpec(command, [])
if (spec.args.length === 0 && spec.command !== command) files.push(realpathSync(spec.command))
return files.map(file => { const stat = statSync(file); return `${file}:${stat.mtimeMs}:${stat.size}` }).join("|")
} catch { return undefined }
}
/** Synchronous, spawn-free lookup for resolution paths. A miss or an expired
* unknown starts a bounded background probe and reports unknown. */
export function cachedForeignProbe(command: string): ForeignProbe {
const key = identity(command)
if (!key) return undefined
const cached = cachedResult(command, key)
if (cached) return cached.result
void probeForeignCommand(command)
return undefined
}
/** Probes at most once at a time per command, coalescing concurrent callers
* (including forced rechecks) onto the running probe. Definite results are
* cached per file identity; timeouts are cached as unknown only for a bounded
* delay, so a hanging wrapper is neither re-run on every read nor trusted forever. */
export function probeForeignCommand(command: string, options: ForeignProbeOptions = {}): Promise<ForeignProbe> {
const key = identity(command)
if (!key) return Promise.resolve(undefined)
// Even a changed file waits for the running probe: never two at once.
const inflight = pending.get(command)
if (inflight) return inflight.promise
const cached = options.force ? undefined : cachedResult(command, key)
if (cached) return Promise.resolve(cached.result)
const unknownTtlMs = options.unknownTtlMs ?? FOREIGN_PROBE_UNKNOWN_TTL_MS
const promise = boundedVersionProbe(command, options.timeoutMs ?? FOREIGN_PROBE_TIMEOUT_MS).then(result => {
if (identity(command) === key) results.set(command, { key, result, ...(result ? {} : { expiresAt: Date.now() + unknownTtlMs }) })
return result
}).finally(() => { if (pending.get(command)?.promise === promise) pending.delete(command) })
pending.set(command, { key, promise })
return promise
}
/** Asynchronous `--version` with a deadline. On timeout or output overflow the
* whole process tree is killed: wrappers may start descendants. */
export function boundedVersionProbe(command: string, timeoutMs: number): Promise<ForeignProbe> {
return new Promise(resolve => {
let spec: ReturnType<typeof buildSpawnSpec>
try { spec = buildSpawnSpec(command, ["--version"]) } catch { return resolve({ valid: false }) }
const posix = process.platform !== "win32"
let child: ChildProcess
try {
child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, windowsHide: true, detached: posix,
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments), stdio: ["ignore", "pipe", "pipe"] })
} catch { return resolve({ valid: false }) }
let stdout = "", stderr = "", status: number | null = null, settled = false
let drain: NodeJS.Timeout | undefined
const finish = (result: ForeignProbe) => {
if (settled) return
settled = true
clearTimeout(timer); clearTimeout(drain)
child.stdout?.destroy(); child.stderr?.destroy()
resolve(result)
}
const abort = () => { killTree(child); finish(undefined) }
const collect = (stream: "stdout" | "stderr") => (chunk: Buffer) => {
if (stream === "stdout") stdout += chunk.toString("utf8"); else stderr += chunk.toString("utf8")
if (stdout.length + stderr.length > OUTPUT_LIMIT) abort()
}
child.stdout?.on("data", collect("stdout"))
child.stderr?.on("data", collect("stderr"))
child.on("error", () => finish({ valid: false }))
// A descendant may keep the pipes open after the probed process exits.
child.on("exit", code => { status = code; drain = setTimeout(() => finish(parse()), 250) })
child.on("close", code => finish(parse(code)))
const parse = (code = status): ForeignProbe => {
const parsed = parseBinaryVersion({ status: code, stdout, stderr })
return parsed.valid ? { valid: true, ...(parsed.version ? { version: parsed.version } : {}) } : { valid: false }
}
const timer = setTimeout(abort, timeoutMs)
})
}
function killTree(child: ChildProcess) {
if (!child.pid) return
if (process.platform === "win32") {
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => {})
return
}
try { process.kill(-child.pid, "SIGKILL") } catch { child.kill("SIGKILL") }
}

View file

@ -76,3 +76,11 @@ test("output-limit termination retains the lock; ordinary installer failure rele
await assert.rejects(withInstallationLock(prefix, async () => {}), { code: "installation_busy" })
} finally { await rm(prefix, { recursive: true, force: true }) }
})
test("npm failures expose only npm's symbolic error code for classification", async () => {
const script = "process.stderr.write('npm error code EBUSY\\r\\nnpm error path C:\\\\private\\\\opencode.exe\\r\\n'); process.exit(1)"
await assert.rejects(executeInstaller(process.execPath, ["-e", script], process.env), (error: unknown) =>
(error as { npmCode?: string }).npmCode === "EBUSY" && !/private/.test((error as Error).message))
await assert.rejects(executeInstaller(process.execPath, ["-e", "process.exit(1)"], process.env), (error: unknown) =>
(error as { npmCode?: string }).npmCode === undefined)
})

View file

@ -0,0 +1,430 @@
import assert from "node:assert/strict"
import test from "node:test"
import { existsSync } from "node:fs"
import { chmod, link, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { installSharedOpenCode, LegacyReplacementError, npmCommandDirectory, npmExecutable, resolveDefaultInstallation,
sharedInstallationPlan, userNpmPrefix, type InstallationHost } from "./shared-installation"
import { legacyNativeImages, legacyNpmInstallation, legacyNpmPackageRoot } from "./legacy-installation"
import { cachedForeignProbe, probeForeignCommand } from "./foreign-probe"
import { InstallationBusyError } from "./installation-lock"
import { BinaryResolver } from "../settings/binaries"
import type { SettingsService } from "../settings/service"
const windows = process.platform === "win32"
// npm's cmd-shim output for a native executable bin.
const cmdShim = (target: string) => ["@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0",
`"%dp0%\\${target.replaceAll("/", "\\")}" %*`, ""].join("\r\n")
async function shim(prefix: string, name: string, target: string) {
const directory = npmCommandDirectory(prefix)
await mkdir(directory, { recursive: true })
if (windows) {
await writeFile(path.join(directory, `${name}.cmd`), cmdShim(target))
await writeFile(path.join(directory, name), `#!/bin/sh\nexec "$basedir/${target}" "$@"\n`)
await writeFile(path.join(directory, `${name}.ps1`), `& "$basedir/${target}" $args\n`)
return path.join(directory, `${name}.cmd`)
}
const command = path.join(directory, name)
await symlink(path.join("..", "lib", target), command)
return command
}
/** OpenCode 1.x as published on npm: `opencode-ai` with an `opencode` native bin. */
async function legacyFixture(prefix: string, version = "1.18.10") {
const root = legacyNpmPackageRoot(prefix, process.platform)
await mkdir(path.join(root, "bin"), { recursive: true })
await writeFile(path.join(root, "bin", "opencode.exe"), version, { mode: 0o755 })
await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "opencode-ai", version, bin: { opencode: "bin/opencode.exe" } }))
return shim(prefix, "opencode", "node_modules/opencode-ai/bin/opencode.exe")
}
/** OpenCode 2 as published on npm: `@opencode/cli` with `opencode` and `opencode2` bins. */
async function cliFixture(prefix: string, version: string) {
const binary = npmExecutable(prefix)
await mkdir(path.dirname(binary), { recursive: true })
await writeFile(binary, version, { mode: 0o755 })
await writeFile(path.join(path.dirname(binary), "..", "package.json"), JSON.stringify({ name: "@opencode/cli", version,
bin: { opencode: "./bin/opencode.exe", opencode2: "./bin/opencode.exe" } }))
for (const name of ["opencode", "opencode2"]) await shim(prefix, name, "node_modules/@opencode/cli/bin/opencode.exe")
}
async function removeShims(prefix: string, name: string) {
for (const suffix of windows ? ["", ".cmd", ".ps1"] : [""]) await rm(path.join(npmCommandDirectory(prefix), `${name}${suffix}`), { force: true })
}
function installer(prefix: string, calls: string[], fail?: () => Error | undefined) {
return async (_node: string, args: string[]) => {
assert.ok(existsSync(path.join(prefix, ".codenomad-opencode-install.lock")), "npm runs under the installation lock")
assert.equal(args[args.indexOf("--prefix") + 1], prefix)
const operation = args[1]!, target = args.at(-1)!
calls.push(`${operation} ${target}`)
if (operation === "uninstall") {
await rm(legacyNpmPackageRoot(prefix, process.platform), { recursive: true, force: true })
await removeShims(prefix, "opencode")
return
}
const error = fail?.()
if (error) throw error
// npm refuses to link a bin over another package's shim without --force.
if (existsSync(legacyNpmPackageRoot(prefix, process.platform))) throw new Error("EEXIST: file already exists")
await cliFixture(prefix, target.split("@").at(-1)!)
}
}
const probe = async (binary: string) => {
try { return { valid: true, version: await readFile(binary, "utf8") } } catch { return { valid: false, missing: true } }
}
async function environment(name: string) {
const home = await mkdtemp(path.join(os.tmpdir(), name))
const env: NodeJS.ProcessEnv = { PATH: "", APPDATA: path.join(home, "Roaming"), LOCALAPPDATA: path.join(home, "Local"),
ProgramFiles: path.join(home, "Program Files"), NPM_CONFIG_PREFIX: windows ? undefined : path.join(home, ".local") }
const host: InstallationHost = { home, env }
return { home, env, host, prefix: userNpmPrefix(host) }
}
/** winget portable layout: Links\opencode.exe (symlink, or hardlink fallback) to Packages\<id>_<source>\opencode.exe. */
async function wingetFixture(local: string, form: "symlink" | "hardlink") {
const packageDirectory = path.join(local, "Microsoft", "WinGet", "Packages", "SST.opencode_Microsoft.Winget.Source_8wekyb3d8bbwe")
const links = path.join(local, "Microsoft", "WinGet", "Links")
await mkdir(packageDirectory, { recursive: true })
await mkdir(links, { recursive: true })
const target = path.join(packageDirectory, "opencode.exe")
await writeFile(target, "1.19.0")
const command = path.join(links, "opencode.exe")
if (form === "symlink") await symlink(target, command, "file")
else await link(target, command)
return { command, links, target }
}
test("OpenCode 1.x npm shims are identified by package manifest and shim target only", async () => {
const { home, prefix } = await environment("legacy-identity-")
try {
const command = await legacyFixture(prefix)
const directory = npmCommandDirectory(prefix)
assert.deepEqual(legacyNpmInstallation(command, prefix, directory, process.platform),
{ command, prefix, binary: path.join(legacyNpmPackageRoot(prefix, process.platform), "bin", "opencode.exe"), version: "1.18.10" })
const manifest = path.join(legacyNpmPackageRoot(prefix, process.platform), "package.json")
await writeFile(manifest, JSON.stringify({ name: "opencode-ai", version: "2.0.30", bin: { opencode: "bin/opencode.exe" } }))
assert.equal(legacyNpmInstallation(command, prefix, directory, process.platform), undefined, "a supported version is never replaced")
await writeFile(manifest, JSON.stringify({ name: "other", version: "1.18.10", bin: { opencode: "bin/opencode.exe" } }))
assert.equal(legacyNpmInstallation(command, prefix, directory, process.platform), undefined)
await legacyFixture(prefix)
const wrapper = path.join(home, "wrapper.exe")
await writeFile(wrapper, "custom")
await rm(command)
if (windows) await writeFile(command, `@echo off\r\n"${wrapper}" %*\r\n`)
else await symlink(wrapper, command)
assert.equal(legacyNpmInstallation(command, prefix, directory, process.platform), undefined, "custom wrappers are not npm shims")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("legacy npm OpenCode is replaced in its prefix only after explicit consent, under the lock", async () => {
const { home, env, host, prefix } = await environment("legacy-replace-")
try {
const command = await legacyFixture(prefix)
env.PATH = npmCommandDirectory(prefix)
assert.deepEqual(resolveDefaultInstallation(host), { path: command, source: "path" })
const plan = sharedInstallationPlan(host)
assert.equal(plan.prefix, prefix)
assert.equal(plan.legacy?.version, "1.18.10")
assert.equal(plan.foreign, undefined)
const calls: string[] = []
let registered = 0
const options = { ...host, node: process.execPath, npm: "fixture-npm.js", execute: installer(prefix, calls), probe,
registerPath: async () => { registered++ } }
await assert.rejects(installSharedOpenCode("2.0.30", options),
(error: unknown) => error instanceof LegacyReplacementError && error.code === "legacy_replacement_required")
assert.deepEqual(calls, [])
assert.ok(existsSync(command), "nothing changes without consent")
for (const stale of [{ path: command, version: "1.18.9" }, { path: path.join(home, "other", "opencode.cmd"), version: "1.18.10" }]) {
await assert.rejects(installSharedOpenCode("2.0.30", { ...options, replaceLegacy: stale }),
(error: unknown) => error instanceof LegacyReplacementError && error.code === "legacy_replacement_required")
}
assert.deepEqual(calls, [], "consent applies only to the exact installation shown")
assert.equal(await installSharedOpenCode("2.0.30", { ...options, replaceLegacy: { path: command, version: "1.18.10" } }), npmExecutable(prefix))
assert.deepEqual(calls, ["uninstall opencode-ai", "install @opencode/cli@2.0.30"])
assert.equal(registered, 1)
assert.equal(existsSync(path.join(prefix, ".codenomad-opencode-install.lock")), false)
assert.equal(resolveDefaultInstallation(host).source, "path")
assert.deepEqual(sharedInstallationPlan(host), { prefix })
} finally { await rm(home, { recursive: true, force: true }) }
})
test("an OpenCode 2 installation failure after removing 1.x is reported and remains retryable", async () => {
const { home, env, host, prefix } = await environment("legacy-failure-")
try {
const command = await legacyFixture(prefix)
env.PATH = npmCommandDirectory(prefix)
const calls: string[] = []
let offline = true
const options = { ...host, node: process.execPath, npm: "fixture-npm.js", probe, registerPath: async () => {},
execute: installer(prefix, calls, () => offline ? new Error("ENOTFOUND registry.npmjs.org") : undefined) }
await assert.rejects(installSharedOpenCode("2.0.30", { ...options, replaceLegacy: { path: command, version: "1.18.10" } }), (error: unknown) =>
error instanceof LegacyReplacementError && error.code === "legacy_removed_install_failed" && /ENOTFOUND/.test(error.message))
assert.deepEqual(calls, ["uninstall opencode-ai", "install @opencode/cli@2.0.30"], "never retried through another mechanism")
assert.equal(existsSync(path.join(prefix, ".codenomad-opencode-install.lock")), false)
assert.deepEqual(sharedInstallationPlan(host), { prefix }, "retry is an ordinary installation without the removed package")
offline = false
await installSharedOpenCode("2.0.30", options)
assert.deepEqual(calls.slice(2), ["install @opencode/cli@2.0.30"])
} finally { await rm(home, { recursive: true, force: true }) }
})
for (const form of ["symlink", "hardlink"] as const) test(`winget OpenCode 1.x (${form}) stays untouched; OpenCode 2 installs beside it and is preferred`, async (t) => {
if (!windows) return t.skip("winget layouts are Windows-only")
const { home, env, host, prefix } = await environment(`winget-${form}-`)
try {
let winget
try { winget = await wingetFixture(env.LOCALAPPDATA!, form) }
catch (error) { if ((error as NodeJS.ErrnoException).code === "EPERM") return t.skip("symlinks need Developer Mode"); throw error }
env.PATH = [winget.links, npmCommandDirectory(prefix)].join(";")
const probes: string[] = []
const probeForeign = (command: string) => { probes.push(command); return { valid: true, version: "1.19.0" } }
const wingetHost = { ...host, probeForeign }
assert.deepEqual(resolveDefaultInstallation(wingetHost), { path: winget.command, source: "path" })
assert.deepEqual(sharedInstallationPlan(wingetHost), { prefix, foreign: { command: winget.command, version: "1.19.0", source: "winget",
...(form === "symlink" ? { packageId: "SST.opencode" } : {}) } })
const calls: string[] = []
await installSharedOpenCode("2.0.30", { ...wingetHost, node: process.execPath, npm: "fixture-npm.js", probe,
execute: installer(prefix, calls), registerPath: async () => {} })
assert.deepEqual(calls, ["install @opencode/cli@2.0.30"])
assert.deepEqual(resolveDefaultInstallation(wingetHost), { path: npmExecutable(prefix), source: "managed" })
assert.equal(sharedInstallationPlan(wingetHost).prefix, prefix, "the managed installation keeps receiving updates")
assert.equal(await readFile(winget.target, "utf8"), "1.19.0")
assert.ok(existsSync(winget.command))
assert.ok(probes.every(command => command === winget.command))
} finally { await rm(home, { recursive: true, force: true }) }
})
test("winget OpenCode first on PATH with npm OpenCode 1.x behind it replaces only the npm package", async (t) => {
if (!windows) return t.skip("winget layouts are Windows-only")
const { home, env, host, prefix } = await environment("winget-npm-")
try {
const winget = await wingetFixture(env.LOCALAPPDATA!, "hardlink")
await legacyFixture(prefix)
env.PATH = [winget.links, npmCommandDirectory(prefix)].join(";")
const wingetHost = { ...host, probeForeign: () => ({ valid: true, version: "1.19.0" }) }
const plan = sharedInstallationPlan(wingetHost)
assert.equal(plan.prefix, prefix)
assert.equal(plan.legacy?.version, "1.18.10")
assert.equal(plan.foreign?.source, "winget")
const calls: string[] = []
const options = { ...wingetHost, node: process.execPath, npm: "fixture-npm.js", probe, execute: installer(prefix, calls), registerPath: async () => {} }
await assert.rejects(installSharedOpenCode("2.0.30", options), /explicit confirmation/)
await installSharedOpenCode("2.0.30", { ...options, replaceLegacy: { path: plan.legacy!.command, version: "1.18.10" } })
assert.deepEqual(calls, ["uninstall opencode-ai", "install @opencode/cli@2.0.30"])
assert.deepEqual(resolveDefaultInstallation(wingetHost), { path: npmExecutable(prefix), source: "managed" })
assert.equal(await readFile(winget.target, "utf8"), "1.19.0")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("valid foreign OpenCode 2 keeps PATH precedence; unusable foreign commands yield to managed OpenCode 2", async () => {
const { home, env, host, prefix } = await environment("foreign-")
try {
await cliFixture(prefix, "2.0.30")
const foreign = path.join(home, "tools", windows ? "opencode.exe" : "opencode")
await mkdir(path.dirname(foreign), { recursive: true })
await writeFile(foreign, "foreign", { mode: 0o755 })
env.PATH = [path.dirname(foreign), npmCommandDirectory(prefix)].join(path.delimiter)
// Valid OpenCode 2, custom labels and unknown (not yet probed or timed out) keep PATH precedence.
for (const probeForeign of [() => ({ valid: true, version: "2.0.31" }), () => ({ valid: true, version: "local-build" }), () => undefined]) {
assert.deepEqual(resolveDefaultInstallation({ ...host, probeForeign }), { path: foreign, source: "path" })
assert.deepEqual(sharedInstallationPlan({ ...host, probeForeign }), {})
}
for (const probeForeign of [() => ({ valid: false }), () => ({ valid: true, version: "0.0.0-beta-19271" }), () => ({ valid: true, version: "1.18.10" })]) {
assert.deepEqual(resolveDefaultInstallation({ ...host, probeForeign }), { path: npmExecutable(prefix), source: "managed" })
assert.equal(sharedInstallationPlan({ ...host, probeForeign }).foreign?.source, "other")
}
assert.equal(await readFile(foreign, "utf8"), "foreign")
} finally { await rm(home, { recursive: true, force: true }) }
})
/** opencode-ai 1.0-1.15.0: a Node launcher bin, a bin/.opencode copy and the
* native executable in a nested platform package. */
async function launcherFixture(prefix: string, version = "1.15.0") {
const root = legacyNpmPackageRoot(prefix, process.platform)
const nested = path.join(root, "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
await mkdir(path.dirname(nested), { recursive: true })
await writeFile(nested, version)
await mkdir(path.join(root, "bin"), { recursive: true })
await writeFile(path.join(root, "bin", "opencode"), "#!/usr/bin/env node\n", { mode: 0o755 })
await writeFile(path.join(root, "bin", ".opencode"), version)
await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "opencode-ai", version, bin: { opencode: "bin/opencode" } }))
const directory = npmCommandDirectory(prefix)
await mkdir(directory, { recursive: true })
if (!windows) {
const command = path.join(directory, "opencode")
await symlink(path.join("..", "lib", "node_modules", "opencode-ai", "bin", "opencode"), command)
return { command, nested }
}
// npm's cmd-shim output for a Node script bin.
const command = path.join(directory, "opencode.cmd")
await writeFile(command, ["@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0", "",
'IF EXIST "%dp0%\\node.exe" (', ' SET "_prog=%dp0%\\node.exe"', ") ELSE (", ' SET "_prog=node"', " SET PATHEXT=%PATHEXT:;.JS;=;%", ")", "",
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*', ""].join("\r\n"))
return { command, nested }
}
test("opencode-ai 1.0-1.15.0 Node-launcher installs are recognized and every native image is preflighted", async (t) => {
const { home, env, host, prefix } = await environment("legacy-launcher-")
try {
const { command, nested } = await launcherFixture(prefix)
env.PATH = npmCommandDirectory(prefix)
const plan = sharedInstallationPlan(host)
assert.equal(plan.legacy?.command, command)
assert.equal(plan.legacy?.version, "1.15.0")
const root = legacyNpmPackageRoot(prefix, process.platform)
assert.deepEqual(legacyNativeImages(root).sort(), [path.join(root, "bin", ".opencode"), path.join(root, "bin", "opencode"), nested].sort())
if (!windows) return t.skip("running-image preflight is Windows-only")
// A read-only file fails the same write-open preflight as a mapped image.
await chmod(nested, 0o444)
const calls: string[] = []
try {
await assert.rejects(installSharedOpenCode("2.0.30", { ...host, node: process.execPath, npm: "fixture-npm.js", probe,
execute: installer(prefix, calls), registerPath: async () => {}, replaceLegacy: { path: command, version: "1.15.0" } }),
(error: unknown) => error instanceof InstallationBusyError && error.code === "installation_in_use")
} finally { await chmod(nested, 0o644) }
assert.deepEqual(calls, [], "npm never runs while a native image is in use")
assert.equal(sharedInstallationPlan(host).legacy?.version, "1.15.0", "nothing was removed")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("npm uninstall failures report whether OpenCode 1.x is still installed", async () => {
const { home, env, host, prefix } = await environment("legacy-uninstall-")
try {
const command = await legacyFixture(prefix)
env.PATH = npmCommandDirectory(prefix)
const consent = { path: command, version: "1.18.10" }
const attempt = (execute: (node: string, args: string[]) => Promise<void>) => installSharedOpenCode("2.0.30", {
...host, node: process.execPath, npm: "fixture-npm.js", probe, execute, registerPath: async () => {}, replaceLegacy: consent })
const failure = (npmCode?: string) => Object.assign(new Error("OpenCode installation failed (1)"), npmCode ? { npmCode } : {})
for (const code of ["EBUSY", "EPERM"]) {
await assert.rejects(attempt(async () => { throw failure(code) }),
(error: unknown) => error instanceof InstallationBusyError && error.code === "installation_in_use" && /still installed/.test(error.message))
}
await assert.rejects(attempt(async () => { throw failure("E500") }), (error: unknown) =>
!(error instanceof LegacyReplacementError) && /still installed/.test((error as Error).message))
assert.equal(sharedInstallationPlan(host).legacy?.version, "1.18.10")
assert.equal(existsSync(path.join(prefix, ".codenomad-opencode-install.lock")), false)
await assert.rejects(attempt(async () => {
await rm(path.join(legacyNpmPackageRoot(prefix, process.platform), "bin"), { recursive: true })
throw failure("EBUSY")
}), (error: unknown) => error instanceof LegacyReplacementError && error.code === "legacy_partially_removed")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("foreign version probes are asynchronous, bounded and kill the process tree on timeout", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "foreign-probe-"))
try {
const quick = path.join(home, windows ? "quick.cmd" : "quick")
await writeFile(quick, windows ? "@echo opencode 1.19.0\r\n" : "#!/bin/sh\necho opencode 1.19.0\n", { mode: 0o755 })
assert.deepEqual(cachedForeignProbe(quick), undefined, "a cache miss reports unknown without spawning synchronously")
assert.deepEqual(await probeForeignCommand(quick), { valid: true, version: "1.19.0" })
assert.deepEqual(cachedForeignProbe(quick), { valid: true, version: "1.19.0" })
await writeFile(quick, windows ? "@echo opencode 2.0.30 \r\n" : "#!/bin/sh\necho opencode 2.0.30 \n", { mode: 0o755 })
assert.equal(cachedForeignProbe(quick), undefined, "a changed file invalidates the cached decision")
assert.deepEqual(await probeForeignCommand(quick), { valid: true, version: "2.0.30" })
const pidFile = path.join(home, "grandchild.pid")
const script = path.join(home, "hang.js")
await writeFile(script, "require('fs').writeFileSync(process.argv[2], String(process.pid)); setInterval(() => {}, 1000)\n")
const hang = path.join(home, windows ? "hang.cmd" : "hang")
await writeFile(hang, windows ? `@"${process.execPath}" "${script}" "${pidFile}"\r\n`
: `#!/bin/sh\n"${process.execPath}" "${script}" "${pidFile}" &\nwait\n`, { mode: 0o755 })
const started = Date.now()
assert.equal(await probeForeignCommand(hang, { timeoutMs: 1_500 }), undefined, "a timeout is unknown, not unusable")
assert.ok(Date.now() - started < 5_000)
assert.equal(cachedForeignProbe(hang), undefined)
const pid = Number(await readFile(pidFile, "utf8"))
const deadline = Date.now() + 5_000
let alive = true
while (alive && Date.now() < deadline) {
try { process.kill(pid, 0); await new Promise(resolve => setTimeout(resolve, 100)) } catch { alive = false }
}
assert.equal(alive, false, "the wrapper's descendant is killed")
} finally { await rm(home, { recursive: true, force: true }) }
})
/** A command that records each start in `<name>.runs`, then prints a version or hangs. */
async function countingCommand(home: string, name: string, behavior: "version" | "hang") {
const runs = path.join(home, `${name}.runs`)
const script = path.join(home, `${name}.js`)
await writeFile(script, `require('fs').appendFileSync(${JSON.stringify(runs)}, 'x');`
+ (behavior === "version" ? "console.log('opencode 1.19.0')\n" : "setInterval(() => {}, 1000)\n"))
const command = path.join(home, windows ? `${name}.cmd` : name)
await writeFile(command, windows ? `@"${process.execPath}" "${script}"\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${script}"\n`, { mode: 0o755 })
return { command, runs: async () => { try { return (await readFile(runs, "utf8")).length } catch { return 0 } } }
}
const settle = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
test("timed-out probes are shared, not re-run on every read, and expire after a bounded delay", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "foreign-probe-ttl-"))
try {
const hang = await countingCommand(home, "hang", "hang")
const options = { timeoutMs: 1_000, unknownTtlMs: 2_500 }
// Concurrent status reads and resolution lookups join the single running probe.
const shared = [probeForeignCommand(hang.command, options), probeForeignCommand(hang.command, options),
probeForeignCommand(hang.command, { ...options, force: true })]
assert.equal(cachedForeignProbe(hang.command), undefined)
assert.deepEqual(await Promise.all(shared), [undefined, undefined, undefined])
await settle(300)
assert.equal(await hang.runs(), 1, "one probe for concurrent callers, including an explicit recheck")
// Within the delay, a still-unknown command is not spawned again.
for (let read = 0; read < 5; read++) {
assert.equal(cachedForeignProbe(hang.command), undefined)
assert.equal(await probeForeignCommand(hang.command, options), undefined)
}
await settle(300)
assert.equal(await hang.runs(), 1, "a hanging command is not re-run on every status read")
// After the delay, the next read probes again (e.g. once an antivirus scan finished).
await settle(2_500)
assert.equal(await probeForeignCommand(hang.command, options), undefined)
await settle(300)
assert.equal(await hang.runs(), 2, "an expired unknown is re-probed")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("an explicit recheck re-probes immediately; definite results last until the file changes", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "foreign-probe-recheck-"))
try {
const hang = await countingCommand(home, "hang", "hang")
assert.equal(await probeForeignCommand(hang.command, { timeoutMs: 1_000 }), undefined)
await settle(300)
assert.equal(await probeForeignCommand(hang.command, { timeoutMs: 1_000 }), undefined)
await settle(300)
assert.equal(await hang.runs(), 1, "the default delay keeps the unknown result")
assert.equal(await probeForeignCommand(hang.command, { timeoutMs: 1_000, force: true }), undefined)
await settle(300)
assert.equal(await hang.runs(), 2, "the explicit recheck spawns a fresh probe within the delay")
const quick = await countingCommand(home, "quick", "version")
assert.deepEqual(await probeForeignCommand(quick.command, { unknownTtlMs: 1 }), { valid: true, version: "1.19.0" })
await settle(50)
assert.deepEqual(cachedForeignProbe(quick.command), { valid: true, version: "1.19.0" }, "definite results do not expire")
assert.deepEqual(await probeForeignCommand(quick.command), { valid: true, version: "1.19.0" })
assert.equal(await quick.runs(), 1)
assert.deepEqual(await probeForeignCommand(quick.command, { force: true }), { valid: true, version: "1.19.0" })
assert.equal(await quick.runs(), 2, "an explicit recheck also refreshes a definite result")
} finally { await rm(home, { recursive: true, force: true }) }
})
test("an explicit custom executable is never replaced by the managed installation", () => {
const settings = (opencodeBinary?: string) => ({
getOwner: (kind: string) => kind === "config" ? { opencodeBinary } : {},
}) as unknown as SettingsService
const managed = () => ({ path: "/managed/opencode.exe", source: "managed" as const })
assert.deepEqual(new BinaryResolver(settings(), managed).resolveDefault(), { path: "/managed/opencode.exe", label: "opencode.exe", version: undefined, source: "managed" })
assert.equal(new BinaryResolver(settings("/custom/opencode"), managed).resolveDefault().path, "/custom/opencode")
assert.equal(new BinaryResolver(settings("/custom/opencode"), managed).resolveDefault().source, undefined)
})

View file

@ -0,0 +1,100 @@
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
import path from "node:path"
import { isBelowOpenCodeMinimum } from "../opencode/runtime-support"
/** OpenCode 1.x is published on npm as `opencode-ai`; 2.x as `@opencode/cli`.
* Both declare an `opencode` bin, so npm refuses to link 2.x over a 1.x shim. */
export const LEGACY_NPM_PACKAGE = "opencode-ai"
export interface LegacyNpmInstallation {
command: string
prefix: string
binary: string
version: string
}
export function legacyNpmPackageRoot(prefix: string, platform: NodeJS.Platform): string {
return path.join(prefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", LEGACY_NPM_PACKAGE)
}
/** Files a running OpenCode 1.x may have mapped: `bin/` (1.15.1+ copies or
* links the native executable to `bin/opencode.exe`; 1.0-1.15.0 to
* `bin/.opencode`, next to a Node launcher) and the nested platform packages'
* `bin/` (`node_modules/opencode-<os>-<arch>[-variant]`). Bounded; a missing
* directory contributes nothing. */
export function legacyNativeImages(root: string, limit = 64): string[] {
const directories = [path.join(root, "bin")]
try {
for (const entry of readdirSync(path.join(root, "node_modules"), { withFileTypes: true })) {
if (entry.isDirectory() && /^opencode-[a-z0-9-]+$/.test(entry.name)) directories.push(path.join(root, "node_modules", entry.name, "bin"))
}
} catch { /* No nested platform packages. */ }
const images: string[] = []
for (const directory of directories) {
try {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isFile() && images.length < limit) images.push(path.join(directory, entry.name))
}
} catch { /* Missing directory. */ }
}
return images
}
function samePath(left: string, right: string, platform: NodeJS.Platform) {
const normalize = (value: string) => platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value)
return normalize(left) === normalize(right)
}
/** Recognize only the shim npm generated in `commandDirectory` for the legacy
* package's own `opencode` bin, verified through its manifest and shim target.
* A version string alone never identifies a replaceable installation. */
export function legacyNpmInstallation(command: string, prefix: string, commandDirectory: string, platform: NodeJS.Platform): LegacyNpmInstallation | undefined {
if (!samePath(path.dirname(command), commandDirectory, platform)) return undefined
if (path.basename(command).replace(platform === "win32" ? /\.cmd$/i : /$^/, "") !== "opencode") return undefined
const root = legacyNpmPackageRoot(prefix, platform)
try {
const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { name?: string; version?: string; bin?: unknown }
const relative = manifest.bin && typeof manifest.bin === "object" ? (manifest.bin as Record<string, unknown>).opencode : undefined
if (manifest.name !== LEGACY_NPM_PACKAGE || typeof relative !== "string" || typeof manifest.version !== "string"
|| !/^[01]\.\d+\.\d+$/.test(manifest.version) || !isBelowOpenCodeMinimum(manifest.version)) return undefined
const binary = path.resolve(root, relative)
if (!binary.startsWith(root + path.sep) || !statSync(binary).isFile()) return undefined
if (platform === "win32") {
const script = readFileSync(command, "utf8")
if (script.length > 64 * 1024) return undefined
const target = script.match(/(?:%~dp0|%dp0%)[\\/]([^"'\r\n]+?)["']\s+%\*/i)?.[1]
if (!target || !samePath(path.win32.resolve(path.dirname(command), target), binary, platform)) return undefined
} else if (realpathSync(command) !== realpathSync(binary)) return undefined
return { command, prefix, binary, version: manifest.version }
} catch { return undefined }
}
function environmentValue(env: NodeJS.ProcessEnv, name: string) {
return Object.entries(env).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]
}
const WINGET_COMMUNITY_SOURCE = "Microsoft.Winget.Source_8wekyb3d8bbwe"
/** winget portable packages expose commands through a Links directory (a
* symlink, or a hardlink/copy fallback) or, when links are unavailable, by
* adding `<root>\Packages\<PackageIdentifier>_<SourceIdentifier>` to PATH.
* Only default winget locations are recognized; the package ID is reported only
* when the resolved directory name proves it. */
export function wingetInstallation(command: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform): { packageId?: string } | undefined {
if (platform !== "win32") return undefined
const local = environmentValue(env, "LOCALAPPDATA")
const programFiles = environmentValue(env, "ProgramFiles")
const programFilesX86 = environmentValue(env, "ProgramFiles(x86)")
const links = [local && path.join(local, "Microsoft", "WinGet", "Links"), programFiles && path.join(programFiles, "WinGet", "Links")]
const roots = [local && path.join(local, "Microsoft", "WinGet", "Packages"), programFiles && path.join(programFiles, "WinGet", "Packages"),
programFilesX86 && path.join(programFilesX86, "WinGet", "Packages")]
let target = command
try { target = realpathSync(command) } catch { /* Classify the PATH entry itself. */ }
const linked = links.some(directory => directory && samePath(path.dirname(command), directory, platform))
const root = roots.find(directory => directory && path.resolve(target).toLowerCase().startsWith(path.resolve(directory).toLowerCase() + path.sep))
if (!linked && !root) return undefined
const directory = root ? path.relative(root, target).split(path.sep)[0] : undefined
const suffix = `_${WINGET_COMMUNITY_SOURCE}`
const packageId = directory?.endsWith(suffix) ? directory.slice(0, -suffix.length) : undefined
return packageId && /^[A-Za-z0-9][A-Za-z0-9.\-]*$/.test(packageId) ? { packageId } : {}
}

View file

@ -14,12 +14,16 @@ export function bundledNpm(execPath = process.execPath): string | undefined {
export function executeInstaller(file: string, args: string[], env: NodeJS.ProcessEnv,
options: { cwd?: string; timeout?: number } = {}): Promise<void> {
return new Promise((resolve, reject) => {
execFile(file, args, { env, cwd: options.cwd, windowsHide: true, timeout: options.timeout ?? 300_000, maxBuffer: 1024 * 1024 }, error => {
execFile(file, args, { env, cwd: options.cwd, windowsHide: true, timeout: options.timeout ?? 300_000, maxBuffer: 1024 * 1024 }, (error, _stdout, stderr) => {
// Do not return npm's environment/config diagnostics to the browser.
// A timeout/output-limit kill may leave npm descendants running. Keep the
// installation fenced rather than treating parent exit as tree exit.
if (error && (error.killed || error.signal || error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")) reject(new InstallationInterruptedError())
else if (error) reject(new Error(`OpenCode installation failed (${error.code ?? "execution"})`))
else if (error) {
// Only npm's symbolic error code (EPERM, EBUSY, ...) is retained for classification.
const npmCode = String(stderr ?? "").match(/^npm (?:error|ERR!) code ([A-Z][A-Z0-9_]{1,31})\r?$/m)?.[1]
reject(Object.assign(new Error(`OpenCode installation failed (${error.code ?? "execution"})`), npmCode ? { npmCode } : {}))
}
else resolve()
})
})

View file

@ -2,11 +2,12 @@ import assert from "node:assert/strict"
import test from "node:test"
import { OpenCodeUpdateService, OpenCodeUpdateError,
resolveLatestOpenCodeVersion, compareOpenCodeVersionStrings,
type OpenCodeUpdateServiceDeps } from "./service"
type OpenCodeUpdateServiceDeps, type UpgradeOptions } from "./service"
import { MINIMUM_OPENCODE_VERSION as minimum } from "../opencode/runtime-support"
import { rememberRuntime } from "../opencode/compatibility/runtime"
import type { Endpoint } from "@opencode/client/service"
import { probeBinaryVersion } from "../workspaces/spawn"
import { LegacyReplacementError } from "./shared-installation"
function deps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
let version = "0.0.0-beta-19271"
@ -98,6 +99,102 @@ test("user npm PATH repair remains available without an update and never downgra
}
})
test("OpenCode 1.x npm replacement requires explicit consent and reports failure after removal", async () => {
let version = "1.18.10", replaced = false
const requests: UpgradeOptions[] = []
const legacyReplacement = { path: "C:/npm/opencode.cmd", version: "1.18.10" }
const service = new OpenCodeUpdateService(deps({
probeBinary: () => ({ valid: true, version }),
inspectInstallation: () => replaced ? {} : { legacyReplacement },
upgradeBinary: async (_binary, target, options) => {
requests.push(options)
// The first consented attempt fails after removing 1.x.
if (requests.length === 1) throw new LegacyReplacementError("legacy_removed_install_failed", "registry offline")
version = target; replaced = true
return { success: true, version: target }
},
}))
const status = await service.getStatus()
assert.equal(status.canUpgrade, true)
assert.deepEqual(status.legacyReplacement, legacyReplacement)
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "legacy_replacement_required")
for (const stale of [{ ...legacyReplacement, version: "1.18.9" }, { ...legacyReplacement, path: "C:/other/opencode.cmd" }]) {
await assert.rejects(service.upgrade({ replaceLegacy: stale }), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "legacy_replacement_required")
}
assert.deepEqual(requests, [], "no installer runs without consent to the exact installation")
await assert.rejects(service.upgrade({ replaceLegacy: legacyReplacement }), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "legacy_removed_install_failed")
assert.deepEqual(await service.upgrade({ replaceLegacy: legacyReplacement }), { success: true, version: minimum })
assert.deepEqual(requests, [{ replaceLegacy: legacyReplacement }, { replaceLegacy: legacyReplacement }])
assert.equal((await service.getStatus()).legacyReplacement, undefined)
})
test("a broken OpenCode 1.x npm install remains replaceable with consent", async () => {
let installed = false
const legacyReplacement = { path: "C:/npm/opencode.cmd", version: "1.18.10" }
const requests: UpgradeOptions[] = []
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => ({ path: installed ? "C:/npm/opencode2.cmd" : legacyReplacement.path, label: "OpenCode", source: "path" }),
// The published placeholder bin/opencode.exe fails when postinstall did not run.
probeBinary: () => installed ? { valid: true, version: minimum } : { valid: false, error: "Exited with code 1: postinstall script was not run" },
inspectInstallation: () => installed ? {} : { legacyReplacement },
upgradeBinary: async (_binary, target, options) => { requests.push(options); installed = true; return { success: true, version: target } },
}))
const status = await service.getStatus()
assert.equal(status.state, "error")
assert.equal(status.canUpgrade, true)
assert.deepEqual(status.legacyReplacement, legacyReplacement)
await assert.rejects(service.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "legacy_replacement_required")
assert.deepEqual(await service.upgrade({ replaceLegacy: legacyReplacement }), { success: true, version: minimum })
assert.deepEqual(requests, [{ replaceLegacy: legacyReplacement }])
})
test("bounded installation inputs settle before resolution on status and upgrade", async () => {
const order: string[] = []
const service = new OpenCodeUpdateService(deps({
prepareInstallation: async () => { order.push("prepare") },
resolveBinary: () => { order.push("resolve"); return { path: "opencode2", label: "OpenCode" } },
}))
await service.getStatus()
await service.upgrade()
assert.deepEqual(order.slice(0, 2), ["prepare", "resolve"])
assert.equal(order.filter(step => step === "prepare").length, 2)
assert.ok(order.indexOf("prepare", 1) < order.lastIndexOf("resolve"))
})
test("only an explicit recheck asks for fresh installation probes", async () => {
const rechecks: boolean[] = []
const service = new OpenCodeUpdateService(deps({ prepareInstallation: async ({ recheck }) => { rechecks.push(recheck === true) } }))
await service.getStatus()
await service.getStatus({ recheck: true })
await service.upgrade()
assert.deepEqual(rechecks.slice(0, 2), [false, true])
assert.ok(rechecks.slice(2).every(recheck => !recheck), "installation and its follow-up reads never force a probe")
})
test("an unusable foreign command still offers OpenCode 2 beside it, never a repair", async () => {
let installed = false
const foreignInstallation = { path: "C:/winget/Links/opencode.exe", source: "winget" as const, packageId: "SST.opencode" }
const service = new OpenCodeUpdateService(deps({
resolveBinary: () => installed ? { path: "managed", label: "OpenCode", source: "managed" } : { path: foreignInstallation.path, label: "OpenCode", source: "path" },
probeBinary: path => path === "managed" ? { valid: true, version: minimum } : { valid: false, error: "not a valid application" },
inspectInstallation: () => ({ foreignInstallation }),
upgradeBinary: async (_binary, target, options) => {
assert.deepEqual(options, {})
installed = true
return { success: true, version: target }
},
}))
const status = await service.getStatus()
assert.equal(status.state, "error")
assert.equal(status.canUpgrade, true)
assert.deepEqual(status.foreignInstallation, foreignInstallation)
assert.deepEqual(await service.upgrade(), { success: true, version: minimum })
const after = await service.getStatus()
assert.equal(after.installationSource, "managed")
assert.equal(after.canUpgrade, false)
assert.deepEqual(after.foreignInstallation, foreignInstallation, "the untouched older command remains reported")
})
test("coalesces overlapping installations and never downgrades a newer version", async () => {
let upgrades = 0
let version = "2.0.6"

View file

@ -11,7 +11,7 @@ import { createRuntimeTransport } from "../opencode/compatibility/transport"
import type { Endpoint } from "@opencode/client/service"
import { bundledNpm } from "./npm-runtime"
import { isRetiredInstallation } from "./retired-installation"
import { installSharedOpenCode, sharedInstallPrefix } from "./shared-installation"
import { installSharedOpenCode, LegacyReplacementError, prepareForeignProbe, sharedInstallationPlan, sharedInstallPrefix } from "./shared-installation"
import { InstallationBusyError } from "./installation-lock"
import type { OpenCodeServiceLifecycle } from "../workspaces/opencode-service"
import { parseWslUncPath } from "../workspaces/spawn"
@ -22,13 +22,20 @@ const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
type ServiceAction = "start" | "restart" | "reload"
type UpgradeResult = { success: true; version: string } | { success: false; error: string }
/** `replaceLegacy` names the exact OpenCode 1.x installation the user confirmed. */
export type UpgradeOptions = { replaceLegacy?: { path: string; version: string } }
export type InstallationNotice = Pick<OpenCodeUpdateStatus, "legacyReplacement" | "foreignInstallation">
export interface OpenCodeUpdateServiceDeps {
/** Settles bounded, asynchronous inputs (foreign `--version` probes) before resolution. */
prepareInstallation?: (options: { recheck?: boolean }) => Promise<void>
resolveBinary: () => ResolvedBinary
probeBinary: (path: string) => ReturnType<typeof probeBinaryVersion> | Promise<ReturnType<typeof probeBinaryVersion>>
resolveLatestVersion: () => Promise<string>
canUpgradeBinary: (binary: ResolvedBinary) => boolean
upgradeBinary: (binary: ResolvedBinary, target: string) => Promise<UpgradeResult>
upgradeBinary: (binary: ResolvedBinary, target: string, options: UpgradeOptions) => Promise<UpgradeResult>
/** Legacy npm replacement and untouched foreign installations for an upgradable binary. */
inspectInstallation?: (binary: ResolvedBinary) => InstallationNotice
lifecycle?: (binary: ResolvedBinary) => Promise<OpenCodeServiceLifecycle>
reconnect?: (binary: ResolvedBinary) => Promise<void>
admitActivation?: (binary: ResolvedBinary) => void
@ -43,7 +50,10 @@ export class OpenCodeUpdateError extends Error {
| "unsupported_binary"
| "update_check_failed"
| "upgrade_failed"
| "upgrade_verification_failed",
| "upgrade_verification_failed"
| "legacy_replacement_required"
| "legacy_removed_install_failed"
| "legacy_partially_removed",
message: string,
) {
super(message)
@ -56,7 +66,9 @@ export class OpenCodeUpdateService {
constructor(private readonly deps: OpenCodeUpdateServiceDeps) {}
async getStatus(): Promise<OpenCodeUpdateStatus> {
/** `recheck` is an explicit user check: cached probe results are refreshed. */
async getStatus(options: { recheck?: boolean } = {}): Promise<OpenCodeUpdateStatus> {
await this.deps.prepareInstallation?.({ recheck: options.recheck === true })
const binary = this.deps.resolveBinary()
let currentVersion: string | null = null
let missing = false
@ -70,9 +82,13 @@ export class OpenCodeUpdateService {
: currentVersion && !isBelowOpenCodeMinimum(currentVersion) ? "ready" : "update_required"
const updateAvailable = latestVersion && (!currentVersion || comparableVersion(currentVersion))
? !currentVersion || compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 : null
const notice = this.deps.inspectInstallation?.(binary) ?? {}
// An unusable foreign command is never repaired, but OpenCode 2 can still be
// installed beside it; a broken verified 1.x npm install can still be replaced.
const installable = !invalid || replaceableOrForeign(notice, binary)
const status: OpenCodeUpdateStatus = {
currentVersion, latestVersion, updateAvailable,
canUpgrade: !invalid && Boolean(updateAvailable || needsSharedInstallation(binary)) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary),
currentVersion, latestVersion, updateAvailable, ...notice,
canUpgrade: installable && Boolean(updateAvailable || needsSharedInstallation(binary)) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary),
minimumVersion: MINIMUM_OPENCODE_VERSION, recommendedVersion: RECOMMENDED_OPENCODE_VERSION,
versionAssessment: "untested", state, binaryPath: binary.path,
installationSource: binary.source, needsSharedInstallation: needsSharedInstallation(binary),
@ -184,21 +200,24 @@ export class OpenCodeUpdateService {
return this.getStatus()
}
upgrade(): Promise<OpenCodeUpdateResponse> {
async upgrade(options: UpgradeOptions = {}): Promise<OpenCodeUpdateResponse> {
await this.deps.prepareInstallation?.({})
const binary = this.deps.resolveBinary()
const existing = inFlightUpgrades.get(binary.path)
if (existing) return existing
const pending = this.performUpgrade(binary).finally(() => {
const pending = this.performUpgrade(binary, options).finally(() => {
if (inFlightUpgrades.get(binary.path) === pending) inFlightUpgrades.delete(binary.path)
})
inFlightUpgrades.set(binary.path, pending)
return pending
}
private async performUpgrade(binary: ResolvedBinary): Promise<OpenCodeUpdateResponse> {
private async performUpgrade(binary: ResolvedBinary, options: UpgradeOptions): Promise<OpenCodeUpdateResponse> {
const probe = await this.deps.probeBinary(binary.path)
const currentVersion = probe.missing ? null : await this.readCurrentVersion(binary.path, probe)
const notice = this.deps.inspectInstallation?.(binary) ?? {}
const currentVersion = probe.missing || (!probe.valid && replaceableOrForeign(notice, binary))
? null : await this.readCurrentVersion(binary.path, probe)
const latestVersion = await this.readLatestVersion()
assertSupportedOpenCode(latestVersion)
@ -217,9 +236,14 @@ export class OpenCodeUpdateService {
)
}
const legacy = notice.legacyReplacement
if (legacy && (options.replaceLegacy?.path !== legacy.path || options.replaceLegacy.version !== legacy.version)) {
throw new OpenCodeUpdateError("legacy_replacement_required", "Replacing OpenCode 1.x requires confirmation of the current installation")
}
try {
const targetVersion = currentVersion && compareOpenCodeVersionStrings(currentVersion, latestVersion) > 0 ? currentVersion : latestVersion
const result = await this.deps.upgradeBinary(binary, targetVersion)
const result = await this.deps.upgradeBinary(binary, targetVersion, legacy ? { replaceLegacy: options.replaceLegacy } : {})
if (!result.success) {
throw new OpenCodeUpdateError("upgrade_failed", result.error)
}
@ -233,6 +257,7 @@ export class OpenCodeUpdateService {
return { success: true, version: installedVersion }
} catch (error) {
if (error instanceof OpenCodeUpdateError || error instanceof InstallationBusyError) throw error
if (error instanceof LegacyReplacementError) throw new OpenCodeUpdateError(error.code, error.message)
throw new OpenCodeUpdateError(
"upgrade_failed",
error instanceof Error ? error.message : "OpenCode upgrade failed",
@ -299,19 +324,31 @@ export function createOpenCodeUpdateService(
workspaceManager: WorkspaceManager,
): OpenCodeUpdateService {
const binaryResolver = new BinaryResolver(settings)
// Explicit custom executables and WSL selections are never installed over.
const defaultSelection = (binary: ResolvedBinary) => {
const configured = settings.getOwner("config", "server").opencodeBinary
return !parseWslUncPath(binary.path) && (!configured || configured === "opencode" || configured === "opencode2" || (typeof configured === "string" && isRetiredInstallation(configured)))
}
return new OpenCodeUpdateService({
prepareInstallation: options => prepareForeignProbe({}, { force: options.recheck }),
resolveBinary: () => {
const binary = binaryResolver.resolveDefault()
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
},
probeBinary: probeBinaryVersionAsync,
resolveLatestVersion: resolveLatestOpenCodeVersion,
canUpgradeBinary: binary => {
const configured = settings.getOwner("config", "server").opencodeBinary
return !parseWslUncPath(binary.path) && (!configured || configured === "opencode" || configured === "opencode2" || (typeof configured === "string" && isRetiredInstallation(configured))) && Boolean(bundledNpm()) && Boolean(sharedInstallPrefix())
canUpgradeBinary: binary => defaultSelection(binary) && Boolean(bundledNpm()) && Boolean(sharedInstallPrefix()),
inspectInstallation: binary => {
if (!defaultSelection(binary)) return {}
const plan = sharedInstallationPlan()
return {
...(plan.prefix && plan.legacy ? { legacyReplacement: { path: plan.legacy.command, version: plan.legacy.version } } : {}),
...(plan.foreign ? { foreignInstallation: { path: plan.foreign.command, source: plan.foreign.source,
...(plan.foreign.version ? { version: plan.foreign.version } : {}), ...(plan.foreign.packageId ? { packageId: plan.foreign.packageId } : {}) } } : {}),
}
},
upgradeBinary: async (_binary, version) => {
await installSharedOpenCode(version)
upgradeBinary: async (_binary, version, options) => {
await installSharedOpenCode(version, { replaceLegacy: options.replaceLegacy })
return { success: true, version }
},
lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle),
@ -335,3 +372,9 @@ function comparableVersion(version: string): boolean {
function needsSharedInstallation(binary: ResolvedBinary): boolean {
return binary.source === "user"
}
/** The failing binary is a foreign command (installed beside) or a verified
* OpenCode 1.x npm shim (replaced with consent), never repaired in place. */
function replaceableOrForeign(notice: InstallationNotice, binary: ResolvedBinary): boolean {
return notice.foreignInstallation?.path === binary.path || notice.legacyReplacement?.path === binary.path
}

View file

@ -9,6 +9,8 @@ import { withInstallationLock } from "./installation-lock"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
const validV2 = () => ({ valid: true, version: "2.0.30" })
async function npmFixture(prefix: string, version: string) {
const binary = npmExecutable(prefix)
await mkdir(path.dirname(binary), { recursive: true })
@ -110,7 +112,7 @@ test("standalone opencode commands and custom npm prefixes are discovered withou
const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-prefix-"))
try {
const prefix = path.join(home, "custom npm")
const host = { home, env: { PATH: "", NPM_CONFIG_PREFIX: prefix } }
const host = { home, env: { PATH: "", NPM_CONFIG_PREFIX: prefix }, probeForeign: validV2 }
assert.equal(userNpmPrefix(host), prefix)
await npmFixture(prefix, "2.0.12")
assert.equal(resolveDefaultInstallation(host).path, npmExecutable(prefix))
@ -170,7 +172,7 @@ test("PATH executable precedence wins over npm shims in the same prefix", async
await writeFile(standalone, "standalone", { mode: 0o755 })
await symlink(npmExecutable(prefix), path.join(directory, "opencode"))
}
const host = { home, env: { PATH: directory } }
const host = { home, env: { PATH: directory }, probeForeign: validV2 }
assert.equal(resolveDefaultInstallation(host).path, standalone)
assert.equal(sharedInstallPrefix(host), undefined)
if (process.platform !== "win32") {
@ -178,8 +180,8 @@ test("PATH executable precedence wins over npm shims in the same prefix", async
await mkdir(sibling)
const unrelated = path.join(sibling, "opencode2")
await writeFile(unrelated, "standalone", { mode: 0o755 })
assert.equal(resolveDefaultInstallation({ home, env: { PATH: sibling } }).path, unrelated)
assert.equal(sharedInstallPrefix({ home, env: { PATH: sibling } }), undefined)
assert.equal(resolveDefaultInstallation({ home, env: { PATH: sibling }, probeForeign: validV2 }).path, unrelated)
assert.equal(sharedInstallPrefix({ home, env: { PATH: sibling }, probeForeign: validV2 }), undefined)
}
} finally { await rm(home, { recursive: true, force: true }) }
})
@ -198,7 +200,7 @@ test("custom Windows command wrapper in an npm prefix wins over the package alia
await writeFile(standalone, "standalone")
const command = path.join(prefix, "opencode2.cmd")
await writeFile(command, `@echo off\r\n"${standalone}" %*\r\n`)
const host = { home, env: { PATH: prefix } }
const host = { home, env: { PATH: prefix }, probeForeign: validV2 }
assert.equal(resolveDefaultInstallation(host).path, command)
assert.equal(sharedInstallPrefix(host), undefined)
} finally { await rm(home, { recursive: true, force: true }) }
@ -209,7 +211,7 @@ test("non-npm PATH executables are reused without allowing npm to shadow them",
try {
const binary = path.join(home, process.platform === "win32" ? "opencode2.exe" : "opencode2")
await writeFile(binary, "custom", { mode: 0o755 })
const options = { home, env: { PATH: home }, npm: "fixture-npm.js" }
const options = { home, env: { PATH: home }, npm: "fixture-npm.js", probeForeign: validV2 }
assert.equal(resolveDefaultInstallation(options).path, binary)
assert.equal(sharedInstallPrefix(options), undefined)
await assert.rejects(installSharedOpenCode("2.0.27", options), /not a writable npm installation/)

View file

@ -5,16 +5,48 @@ import { bundledNpm, executeInstaller } from "./npm-runtime"
import { isRetiredInstallation } from "./retired-installation"
import { registerUserPath } from "./user-path"
import { probeBinaryVersionAsync, buildSpawnSpec } from "../workspaces/spawn"
import { assertSupportedOpenCode } from "../opencode/runtime-support"
import { assertSupportedOpenCode, isBelowOpenCodeMinimum } from "../opencode/runtime-support"
import { compareVersionStrings } from "../releases/release-monitor"
import { assertExecutableWritable, withInstallationLock } from "./installation-lock"
import { assertExecutableWritable, InstallationBusyError, withInstallationLock } from "./installation-lock"
import { upgradeSharedOpenCode } from "./native-upgrade"
import { isIncompleteWindowsNpmShim } from "./incomplete-installation"
import { LEGACY_NPM_PACKAGE, legacyNativeImages, legacyNpmInstallation, legacyNpmPackageRoot, wingetInstallation, type LegacyNpmInstallation } from "./legacy-installation"
import { cachedForeignProbe, probeForeignCommand, type ForeignProbe } from "./foreign-probe"
export interface InstallationHost {
home?: string
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
/** Spawn-free lookup of a non-npm PATH command's version; `undefined` is unknown. */
probeForeign?: (command: string) => ForeignProbe
}
/** The exact OpenCode 1.x installation the user confirmed replacing. */
export interface LegacyReplacementConsent {
path: string
version: string
}
/** A PATH OpenCode that CodeNomad never modifies but no longer prefers. */
export interface ForeignOpenCode {
command: string
version?: string
source: "winget" | "other"
packageId?: string
}
export interface SharedInstallationPlan {
/** Writable npm prefix where `@opencode/cli` is installed or updated. */
prefix?: string
/** OpenCode 1.x npm package that must be removed from `prefix` first, with explicit consent. */
legacy?: LegacyNpmInstallation
foreign?: ForeignOpenCode
}
export class LegacyReplacementError extends Error {
constructor(readonly code: "legacy_replacement_required" | "legacy_removed_install_failed" | "legacy_partially_removed", message: string) {
super(message)
}
}
export function userNpmPrefix(host: InstallationHost = {}): string {
@ -107,35 +139,102 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine
}
}
export function resolveDefaultInstallation(host: InstallationHost = {}): { path: string; source?: "path" | "user" } {
export function resolveDefaultInstallation(host: InstallationHost = {}): { path: string; source?: "path" | "user" | "managed" } {
const platform = host.platform ?? process.platform
const prefix = userNpmPrefix(host)
const binary = npmCommand(prefix, platform)?.binary ?? npmExecutable(prefix, platform)
const managed = npmCommand(prefix, platform)
const binary = managed?.binary ?? npmExecutable(prefix, platform)
const command = findPathOpenCode(host)
if (command) {
if (platform === "win32" && isIncompleteWindowsNpmShim(command, prefix, binary)) return { path: binary, source: "user" }
// A verified user npm `@opencode/cli` wins only over an OpenCode 1.x npm
// shim or a foreign command that is below the minimum or cannot run.
if (managed && supersededPathCommand(command, host)) return { path: managed.binary, source: "managed" }
return { path: command, source: "path" }
}
if (existsSync(binary) && !isRetiredInstallation(binary)) return { path: binary, source: "user" }
return { path: "opencode2" }
}
/** Only a verified npm installation may be updated via npm. Homebrew/curl and
* explicit custom executable choices retain their own installation authority. */
export function sharedInstallPrefix(host: InstallationHost = {}): string | undefined {
/** Foreign commands are superseded only when they cannot run or report a
* comparable version below the minimum; valid, custom-labelled and not yet
* probed (or timed out) commands keep PATH precedence. Resolution never spawns:
* it reads results of the bounded asynchronous probe. */
function supersededForeign(command: string, host: InstallationHost): ForeignOpenCode | undefined {
const platform = host.platform ?? process.platform
const result = (host.probeForeign ?? cachedForeignProbe)(command)
if (!result) return undefined
const version = result.valid ? result.version?.trim() || undefined : undefined
if (result.valid && !(version && /^(?:\d+\.\d+\.\d+|0\.0\.0-beta-\d+)$/.test(version) && isBelowOpenCodeMinimum(version))) return undefined
const winget = wingetInstallation(command, host.env ?? process.env, platform)
return { command, ...(version ? { version } : {}), source: winget ? "winget" : "other", ...(winget?.packageId ? { packageId: winget.packageId } : {}) }
}
function commandPrefix(command: string, platform: NodeJS.Platform) {
return platform === "win32" ? path.dirname(command) : path.dirname(path.dirname(command))
}
function supersededPathCommand(command: string, host: InstallationHost): boolean {
const platform = host.platform ?? process.platform
const prefix = commandPrefix(command, platform)
if (npmLauncher(command, prefix, platform)) return false
if (legacyNpmInstallation(command, prefix, npmCommandDirectory(prefix, platform), platform)) return true
return Boolean(supersededForeign(command, host))
}
function writable(prefix: string, allowMissing = false): boolean {
try { accessSync(prefix, constants.W_OK); return true }
catch (error) { return allowMissing && (error as NodeJS.ErrnoException).code === "ENOENT" }
}
/** npm-managed OpenCode in `prefix` that CodeNomad may update, repair or (with consent) replace. */
function npmTarget(command: string, prefix: string, userPrefix: string, platform: NodeJS.Platform): SharedInstallationPlan | undefined {
if (npmLauncher(command, prefix, platform)
|| (platform === "win32" && isIncompleteWindowsNpmShim(command, userPrefix, npmExecutable(userPrefix, platform)))) {
return writable(prefix) ? { prefix } : {}
}
const legacy = legacyNpmInstallation(command, prefix, npmCommandDirectory(prefix, platform), platform)
return legacy && writable(prefix) ? { prefix, legacy } : undefined
}
/** A verified `@opencode/cli` npm installation may be updated via npm. An
* OpenCode 1.x `opencode-ai` npm shim in the same prefix may be replaced only
* with explicit consent. Below-minimum or unusable foreign commands (winget,
* Homebrew, curl, manual) are never modified: OpenCode 2 is installed in the
* user npm prefix beside them. Valid foreign installations and explicit custom
* executables retain their own installation authority. */
export function sharedInstallationPlan(host: InstallationHost = {}): SharedInstallationPlan {
const platform = host.platform ?? process.platform
const userPrefix = userNpmPrefix(host)
if (isRetiredInstallation(userPrefix)) return undefined
const command = findPathOpenCode(host) ?? findPathOpenCode({ ...host,
env: { PATH: npmCommandDirectory(userPrefix, platform), PATHEXT: ".EXE;.CMD;.BAT" } })
if (!command) return userPrefix
const prefix = platform === "win32" ? path.dirname(command) : path.dirname(path.dirname(command))
try {
if (!npmLauncher(command, prefix, platform)
&& !(platform === "win32" && isIncompleteWindowsNpmShim(command, userPrefix, npmExecutable(userPrefix, platform)))) return undefined
accessSync(prefix, constants.W_OK)
return prefix
} catch { return undefined }
if (isRetiredInstallation(userPrefix)) return {}
const command = findPathOpenCode(host)
const userCommand = findPathOpenCode({ ...host, env: { PATH: npmCommandDirectory(userPrefix, platform), PATHEXT: ".EXE;.CMD;.BAT" } })
if (!command) return userCommand ? npmTarget(userCommand, userPrefix, userPrefix, platform) ?? {} : { prefix: userPrefix }
const target = npmTarget(command, commandPrefix(command, platform), userPrefix, platform)
if (target) return target
const foreign = supersededForeign(command, host)
if (!foreign) return {}
if (!userCommand) return writable(userPrefix, true) ? { prefix: userPrefix, foreign } : { foreign }
return { ...npmTarget(userCommand, userPrefix, userPrefix, platform), foreign }
}
export function sharedInstallPrefix(host: InstallationHost = {}): string | undefined {
return sharedInstallationPlan(host).prefix
}
/** Settles the bounded version probe of a foreign PATH command so the
* synchronous plan/resolution can use it. npm-managed commands are never run. */
export async function prepareForeignProbe(host: InstallationHost = {}, options: { force?: boolean } = {}): Promise<void> {
const platform = host.platform ?? process.platform
const command = findPathOpenCode(host)
if (!command) return
const prefix = commandPrefix(command, platform)
if (npmLauncher(command, prefix, platform) || legacyNpmInstallation(command, prefix, npmCommandDirectory(prefix, platform), platform)) return
await probeForeignCommand(command, { force: options.force })
}
function matchesConsent(legacy: LegacyNpmInstallation, consent: LegacyReplacementConsent | undefined) {
return Boolean(consent && path.resolve(consent.path) === path.resolve(legacy.command) && consent.version === legacy.version)
}
export async function installSharedOpenCode(version: string, options: InstallationHost & {
@ -143,18 +242,71 @@ export async function installSharedOpenCode(version: string, options: Installati
execute?: typeof executeInstaller
probe?: typeof probeBinaryVersionAsync
registerPath?: (directory: string) => Promise<void>
/** Explicit consent to remove exactly this OpenCode 1.x npm installation. */
replaceLegacy?: LegacyReplacementConsent
} = {}): Promise<string> {
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("Installation requires an exact stable OpenCode version")
assertSupportedOpenCode(version)
const platform = options.platform ?? process.platform
const prefix = sharedInstallPrefix(options)
if (!prefix) throw new Error("The PATH executable is not a writable npm installation")
const plan = sharedInstallationPlan(options)
if (!plan.prefix) throw new Error("The PATH executable is not a writable npm installation")
const prefix: string = plan.prefix
const consentRequired = () => new LegacyReplacementError("legacy_replacement_required",
"Replacing OpenCode 1.x requires explicit confirmation of the current installation")
if (plan.legacy && !matchesConsent(plan.legacy, options.replaceLegacy)) throw consentRequired()
const node = options.node ?? process.execPath
const npm = options.npm ?? bundledNpm(node)
if (!npm) throw new Error("npm is unavailable beside the CodeNomad Node runtime")
const bundled = options.npm ?? bundledNpm(node)
if (!bundled) throw new Error("npm is unavailable beside the CodeNomad Node runtime")
const npm: string = bundled
const binary = npmExecutable(prefix, platform)
const probe = options.probe ?? probeBinaryVersionAsync
const npmEnvironment = () => {
const env = { ...(options.env ?? process.env) }
const key = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"
env[key] = `${path.dirname(node)}${platform === "win32" ? ";" : ":"}${env[key] || ""}`
return env
}
return withInstallationLock(prefix, async () => {
const current = sharedInstallationPlan(options)
if (current.prefix !== prefix) throw new Error("The OpenCode installation changed; check its status again")
const legacy = current.legacy
if (!legacy) return install()
// Consent covers only the installation the user saw, rechecked under the lock.
if (!matchesConsent(legacy, options.replaceLegacy)) throw consentRequired()
// Windows cannot remove a running image: defer before npm changes anything.
// 1.0-1.15.0 run a nested platform package's executable (or a bin/.opencode
// copy) through a Node launcher, so check every native file of the package.
const images = legacyNativeImages(legacyNpmPackageRoot(prefix, platform))
for (const image of images) await assertExecutableWritable(image, platform)
try {
await (options.execute ?? executeInstaller)(node, [npm, "uninstall", "--global", "--prefix", prefix,
"--no-audit", "--no-fund", LEGACY_NPM_PACKAGE], npmEnvironment())
} catch (error) {
if (error instanceof InstallationBusyError) throw error
const intact = Boolean(legacyNpmInstallation(legacy.command, prefix, npmCommandDirectory(prefix, platform), platform))
&& images.every(image => existsSync(image))
if (!intact) {
throw new LegacyReplacementError("legacy_partially_removed", "Removing OpenCode 1.x failed after npm changed the installation")
}
const code = (error as { npmCode?: string }).npmCode
if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
throw new InstallationBusyError("installation_in_use", "OpenCode 1.x is in use or not writable; it is still installed")
}
throw new Error(`Removing OpenCode 1.x failed; it is still installed (${error instanceof Error ? error.message : String(error)})`)
}
if (existsSync(legacyNpmPackageRoot(prefix, platform))) {
throw new LegacyReplacementError("legacy_partially_removed", "npm did not completely remove the OpenCode 1.x package")
}
try { return await install() }
catch (error) {
// An interrupted installer retains its lock and diagnosis unchanged.
if (error instanceof InstallationBusyError) throw error
throw new LegacyReplacementError("legacy_removed_install_failed",
`OpenCode 1.x was removed, but installing OpenCode 2 failed: ${error instanceof Error ? error.message : String(error)}`)
}
})
async function install(): Promise<string> {
const oldBinary = npmCommand(prefix, platform)?.binary ?? binary
const existing = await probe(oldBinary)
if (existing.valid && (!existing.version || !/^(?:\d+\.\d+\.\d+|0\.0\.0-beta-\d+)$/.test(existing.version))) {
@ -164,19 +316,17 @@ export async function installSharedOpenCode(version: string, options: Installati
const directory = npmCommandDirectory(prefix, platform)
const commandHost = { ...options, env: { PATH: directory, PATHEXT: ".EXE;.CMD;.BAT" } }
if (!existing.valid || existing.version !== target || !npmCommand(prefix, platform) || oldBinary !== binary) {
const env = { ...(options.env ?? process.env) }
// 2.0.15 -> 2.0.16 is qualified with a live Windows service. Older
// installers and same-version launcher repair keep the npm preflight.
if (existing.valid && existing.version && /^\d+\.\d+\.\d+$/.test(existing.version)
&& compareVersionStrings(existing.version, "2.0.15") >= 0 && existing.version !== target
&& npmCommand(prefix, platform)) {
const env = { ...(options.env ?? process.env) }
await upgradeSharedOpenCode({ binary: oldBinary, version: target, prefix, node, npm, env, platform, execute: options.execute })
} else {
await assertExecutableWritable(oldBinary, platform)
const key = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"
env[key] = `${path.dirname(node)}${platform === "win32" ? ";" : ":"}${env[key] || ""}`
await (options.execute ?? executeInstaller)(node, [npm, "install", "--global", "--prefix", prefix,
"--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", `@opencode/cli@${target}`], env)
"--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", `@opencode/cli@${target}`], npmEnvironment())
}
}
const result = await probe(binary)
@ -188,5 +338,5 @@ export async function installSharedOpenCode(version: string, options: Installati
}
await (options.registerPath ?? (directory => registerUserPath(directory, options)))(directory)
return binary
})
}
}

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import type { Logger } from "../../logger"
import type { OpenCodeUpdateService } from "../../opencode-update/service"
import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service"
import { registerOpenCodeUpdateRoutes } from "./opencode-update"
import { InstallationBusyError } from "../../opencode-update/installation-lock"
@ -30,15 +30,41 @@ test("does not pass request-controlled binary paths to the update service", asyn
})
await app.inject({ method: "GET", url: "/api/opencode/update?binary=untrusted.cmd" })
await app.inject({ method: "GET", url: "/api/opencode/update?recheck=true" })
await app.inject({ method: "GET", url: "/api/opencode/update?recheck=1" })
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { binary: "untrusted.cmd" } })
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { replaceLegacy: true } })
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { replaceLegacy: { path: "C:/npm/opencode.cmd", version: "1.x", extra: 1 } } })
const consent = { path: "C:/npm/opencode.cmd", version: "1.18.10" }
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { replaceLegacy: consent } })
assert.deepEqual(calls, [
{ method: "status", args: [] },
{ method: "upgrade", args: [] },
{ method: "status", args: [] },
{ method: "status", args: [{ recheck: true }] },
{ method: "upgrade", args: [{}] },
{ method: "upgrade", args: [{}] },
{ method: "upgrade", args: [{}] },
{ method: "upgrade", args: [{ replaceLegacy: consent }] },
])
await app.close()
})
test("legacy replacement outcomes expose localized error codes", async () => {
for (const [code, status] of [["legacy_replacement_required", 409], ["legacy_removed_install_failed", 502], ["legacy_partially_removed", 502]] as const) {
const app = Fastify()
registerOpenCodeUpdateRoutes(app, {
service: { upgrade: async () => { throw new OpenCodeUpdateError(code, "private host path") } } as unknown as OpenCodeUpdateService,
logger: { warn() {} } as unknown as Logger,
})
try {
const response = await app.inject({ method: "POST", url: "/api/opencode/update" })
assert.equal(response.statusCode, status)
assert.deepEqual(response.json(), { success: false, error: code })
} finally { await app.close() }
}
})
test("service activation accepts only exclusive explicit restart/reload intents", async () => {
const calls: Array<boolean | "reload"> = []
const app = Fastify()

View file

@ -4,13 +4,15 @@ import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-
import { z } from "zod"
import { InstallationBusyError } from "../../opencode-update/installation-lock"
const legacyConsent = z.object({ path: z.string().min(1).max(4096), version: z.string().regex(/^\d+\.\d+\.\d+$/) }).strict()
interface RouteDeps {
service: OpenCodeUpdateService
logger: Logger
}
function statusCode(error: OpenCodeUpdateError): number {
if (error.code === "unsupported_binary") return 409
if (error.code === "unsupported_binary" || error.code === "legacy_replacement_required") return 409
if (error.code === "binary_unavailable") return 422
return 502
}
@ -32,9 +34,11 @@ export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDe
return reply.code(502).send({ error: "service_activation_failed" })
}
})
app.get("/api/opencode/update", async (_request, reply) => {
app.get("/api/opencode/update", async (request, reply) => {
try {
return await deps.service.getStatus()
// `recheck=1` marks the explicit user check, which refreshes cached probes.
const recheck = (request.query as { recheck?: unknown } | undefined)?.recheck === "1"
return await (recheck ? deps.service.getStatus({ recheck }) : deps.service.getStatus())
} catch (error) {
deps.logger.warn({ err: error }, "Failed to check OpenCode update status")
const failure = requestError(error, "update_check_failed")
@ -43,9 +47,12 @@ export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDe
}
})
app.post("/api/opencode/update", async (_request, reply) => {
app.post("/api/opencode/update", async (request, reply) => {
try {
return await deps.service.upgrade()
// Only an explicit confirmation naming the exact OpenCode 1.x installation
// may remove it from npm; the service rechecks that identity under the lock.
const parsed = legacyConsent.safeParse((request.body as { replaceLegacy?: unknown } | undefined)?.replaceLegacy)
return await deps.service.upgrade(parsed.success ? { replaceLegacy: parsed.data } : {})
} catch (error) {
deps.logger.warn({ err: error }, "Failed to update OpenCode")
const failure = requestError(error, "upgrade_failed")

View file

@ -13,7 +13,7 @@ export interface ResolvedBinary {
path: string
label: string
version?: string
source?: "path" | "user"
source?: "path" | "user" | "managed"
}
function prettyLabel(p: string): string {

View file

@ -41,7 +41,7 @@ export type WslWorkingDirectory =
| { kind: "linux"; path: string }
| { kind: "windows"; path: string }
interface BinaryProbeExecution {
export interface BinaryProbeExecution {
error?: Error
status: number | null
stdout?: string | null
@ -213,7 +213,7 @@ export function probeBinaryVersion(
}
}
function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof probeBinaryVersion> {
export function parseBinaryVersion(result: BinaryProbeExecution): ReturnType<typeof probeBinaryVersion> {
if (result.error) {
return { valid: false, error: result.error.message, ...((result.error as NodeJS.ErrnoException).code === "ENOENT" ? { missing: true } : {}) }
}

View file

@ -0,0 +1,30 @@
import { Show } from "solid-js"
import type { OpenCodeUpdateStatus } from "../../../../server/src/api-types"
import { useI18n } from "../../lib/i18n"
/** Explains an OpenCode 1.x npm replacement or an older OpenCode left untouched elsewhere on PATH. */
export function OpenCodeInstallationNotice(props: { status: OpenCodeUpdateStatus }) {
const { t } = useI18n()
return <>
<Show when={props.status.canUpgrade && props.status.legacyReplacement}>
{legacy => <p class="settings-toggle-caption opencode-installation-text">{t("settings.opencode.setup.legacy.description", legacy())}</p>}
</Show>
<Show when={props.status.foreignInstallation}>
{foreign => <div class="opencode-installation-notice" role="note">
<p class="settings-toggle-caption">{foreign().version
? t("settings.opencode.setup.foreign.found", { version: foreign().version ?? "", path: foreign().path, minimum: props.status.minimumVersion })
: t("settings.opencode.setup.foreign.unusable", { path: foreign().path })}</p>
<Show when={foreign().source === "winget"}>
<p class="settings-toggle-caption">{t("settings.opencode.setup.foreign.winget")}</p>
</Show>
<Show when={props.status.canUpgrade && props.status.installationSource !== "managed"}>
<p class="settings-toggle-caption">{t("settings.opencode.setup.foreign.alongside")}</p>
</Show>
<p class="settings-toggle-caption">{t("settings.opencode.setup.foreign.terminal")}</p>
<Show when={foreign().packageId}>
{packageId => <p class="settings-toggle-caption">{t("settings.opencode.setup.foreign.wingetUninstall")} <code>winget uninstall {packageId()}</code></p>}
</Show>
</div>}
</Show>
</>
}

View file

@ -3,6 +3,8 @@ import { useI18n } from "../../lib/i18n"
import { showConfirmDialog } from "../../stores/alerts"
import { serverSettings } from "../../stores/preferences"
import { OpenCodeSetupProgress } from "./opencode-setup-progress"
import { OpenCodeInstallationNotice } from "./opencode-installation-notice"
import type { OpenCodeUpdateStatus } from "../../../../server/src/api-types"
import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError, openCodeSetupCheckError,
openCodeSetupChecking as checking, openCodeSetupAction as action, openCodeSetupFeedback, openCodeInstallationError,
isOpenCodeConnected, canContinueOpenCodeSetup, continueOpenCodeSetup,
@ -11,9 +13,10 @@ import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetup
export function OpenCodeSetupPanel(props: ParentProps = {}) {
const { t } = useI18n()
const [restartPending, setRestartPending] = createSignal(false)
const [confirmPending, setConfirmPending] = createSignal(false)
let disposed = false
onCleanup(() => { disposed = true })
const disabled = () => busy() || checking() || restartPending()
const disabled = () => busy() || checking() || restartPending() || confirmPending()
const restartForTroubleshooting = async () => {
if (disabled() || openCodeSetupCheckError() || !status()?.canRestart) return
const binary = serverSettings().opencodeBinary
@ -30,6 +33,33 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {
if (!disposed) setRestartPending(false)
}
}
// Replacing OpenCode 1.x removes an npm package: always confirm it, and fence
// the confirmation against a changed executable, status or concurrent action.
const install = async () => {
const legacy = status()?.legacyReplacement
if (!legacy) return void runOpenCodeSetup("install")
if (disabled() || !status()?.canUpgrade) return
const binary = serverSettings().opencodeBinary
setConfirmPending(true)
try {
const confirmed = await showConfirmDialog(t("settings.opencode.setup.legacy.confirm", { version: legacy.version }), {
title: t("settings.opencode.setup.legacy.title", { version: legacy.version }), variant: "warning",
confirmLabel: t("settings.opencode.setup.legacy.action", { version: legacy.version }), cancelLabel: t("alertDialog.actions.cancel"),
})
const current = status()
if (!confirmed || disposed || binary !== serverSettings().opencodeBinary || busy() || checking()
|| !current?.canUpgrade || current.legacyReplacement?.path !== legacy.path) return
// The server re-verifies this exact installation under its lock.
await runOpenCodeSetup("install", { replaceLegacy: { path: legacy.path, version: legacy.version } })
} finally {
if (!disposed) setConfirmPending(false)
}
}
const installLabel = (data: OpenCodeUpdateStatus) => data.legacyReplacement
? t("settings.opencode.setup.legacy.action", { version: data.legacyReplacement.version })
: data.foreignInstallation && data.installationSource !== "managed" ? t("settings.opencode.setup.foreign.install")
: data.needsSharedInstallation ? t("settings.opencode.setup.sharedInstall")
: data.state === "missing" ? t("settings.opencode.setup.install") : t("settings.opencode.update.action", { version: data.latestVersion ?? "" })
const progress = () => checking() ? "settings.opencode.update.checking"
: action() === "install" ? "settings.opencode.update.updating"
: action() ? `settings.opencode.setup.progress.${action()}` : undefined
@ -65,8 +95,8 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {
<p class="settings-toggle-caption">{t("settings.opencode.setup.untested", { version: data().daemonVersion ?? data().currentVersion ?? "" })}</p>
</Show>
<div class="settings-info-actions">
<Show when={data().canUpgrade}><button type="button" class="settings-pill-button" disabled={disabled()} onClick={() => void runOpenCodeSetup("install")}>
{action() === "install" ? t("settings.opencode.update.updating") : data().needsSharedInstallation ? t("settings.opencode.setup.sharedInstall") : data().state === "missing" ? t("settings.opencode.setup.install") : t("settings.opencode.update.action", { version: data().latestVersion ?? "" })}
<Show when={data().canUpgrade}><button type="button" class="settings-pill-button" disabled={disabled()} onClick={() => void install()}>
{action() === "install" ? t("settings.opencode.update.updating") : installLabel(data())}
</button></Show>
<Show when={data().state === "ready" && !isOpenCodeConnected() && data().serviceState !== "restart_required" && data().serviceState !== "incompatible"}>
<button type="button" class="settings-pill-button" disabled={disabled()} onClick={() => void runOpenCodeSetup("start")}>{t("settings.opencode.setup.connect")}</button>
@ -79,6 +109,7 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {
<Show when={data().canUpgrade && (data().state === "missing" || data().needsSharedInstallation)}>
<p class="settings-toggle-caption">{t("settings.opencode.setup.sharedInstallDescription")}</p>
</Show>
<OpenCodeInstallationNotice status={data()} />
</>}
</Show>
<Show when={openCodeSetupCheckError()}><p class="settings-error-message" role="alert">{t("settings.opencode.setup.serviceError")}</p></Show>

View file

@ -605,11 +605,13 @@ export const serverApi = {
body: JSON.stringify({ path }),
})
},
fetchOpenCodeUpdateStatus(): Promise<OpenCodeUpdateStatus> {
return request<OpenCodeUpdateStatus>("/api/opencode/update")
/** `recheck` marks an explicit user check, which refreshes the server's cached version probes. */
fetchOpenCodeUpdateStatus(options: { recheck?: boolean } = {}): Promise<OpenCodeUpdateStatus> {
return request<OpenCodeUpdateStatus>(options.recheck ? "/api/opencode/update?recheck=1" : "/api/opencode/update")
},
updateOpenCode(): Promise<OpenCodeUpdateResponse> {
return request<OpenCodeUpdateResponse>("/api/opencode/update", { method: "POST" })
updateOpenCode(options: { replaceLegacy?: { path: string; version: string } } = {}): Promise<OpenCodeUpdateResponse> {
return request<OpenCodeUpdateResponse>("/api/opencode/update", { method: "POST",
body: JSON.stringify(options.replaceLegacy ? { replaceLegacy: { path: options.replaceLegacy.path, version: options.replaceLegacy.version } } : {}) })
},
startOpenCode(restart = false): Promise<OpenCodeUpdateStatus> {
return request<OpenCodeUpdateStatus>("/api/opencode/service", { method: "POST", body: JSON.stringify({ restart }) })

View file

@ -337,7 +337,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Hinzufügen",
"opencodeBinarySelector.actions.browse": "Nach Binärdatei suchen...",
"opencodeBinarySelector.actions.removeTitle": "Binärdatei entfernen",
"opencodeBinarySelector.badge.systemPath": "Zuerst PATH, dann npm-Benutzerinstallation",
"opencodeBinarySelector.badge.systemPath": "Zuerst PATH, außer bei einem älteren OpenCode, dann OpenCode 2 der npm-Benutzerinstallation",
"opencodeBinarySelector.status.checkingVersions": "Versionen werden geprüft...",
"opencodeBinarySelector.status.checking": "Wird geprüft...",
"opencodeBinarySelector.dialog.title": "OpenCode-Binärdatei auswählen",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "npm-Benutzerinstallation außerhalb des aktuellen PATH gefunden.",
"settings.opencode.setup.sharedInstall": "Für den Benutzer installieren und PATH konfigurieren",
"settings.opencode.setup.sharedInstallDescription": "Verwendet eine normale npm-Installation gemeinsam mit anderen Clients. Danach ein neues Terminal öffnen.",
"settings.opencode.setup.source.managed": "CodeNomad verwendet seine OpenCode-2-Installation in Ihrem npm-Benutzerordner, weil ein älteres OpenCode im PATH zuerst gefunden wird.",
"settings.opencode.setup.foreign.found": "OpenCode {version} wurde unter {path} gefunden. CodeNomad benötigt OpenCode {minimum} oder neuer und verändert diese Installation nicht.",
"settings.opencode.setup.foreign.unusable": "Das unter {path} gefundene OpenCode konnte nicht gestartet werden. CodeNomad verändert diese Installation nicht.",
"settings.opencode.setup.foreign.winget": "Diese ältere Version wurde mit winget installiert.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 wird für CodeNomad in Ihrem npm-Benutzerordner neben der älteren Version installiert.",
"settings.opencode.setup.foreign.terminal": "Je nach PATH-Reihenfolge kann der Befehl opencode im Terminal weiterhin die ältere Version starten.",
"settings.opencode.setup.foreign.wingetUninstall": "Wenn Sie sie nicht mehr benötigen, können Sie sie selbst entfernen mit:",
"settings.opencode.setup.foreign.install": "OpenCode 2 für CodeNomad installieren",
"settings.opencode.setup.legacy.action": "OpenCode {version} durch OpenCode 2 ersetzen",
"settings.opencode.setup.legacy.title": "OpenCode {version} ersetzen?",
"settings.opencode.setup.legacy.description": "OpenCode {version} wurde mit npm installiert ({path}). Beim Ersetzen wird das npm-Paket opencode-ai entfernt und OpenCode 2 im selben npm-Ordner installiert.",
"settings.opencode.setup.legacy.confirm": "Das npm-Paket opencode-ai {version} wird entfernt und OpenCode 2 (@opencode/cli) an seiner Stelle installiert. Der Befehl opencode aus diesem npm-Ordner startet danach OpenCode 2; OpenCode {version} ist darüber nicht mehr verfügbar. Ihr OpenCode-Datenordner wird nicht gelöscht: OpenCode 2 verwendet denselben Ordner und importiert Unterhaltungen aus OpenCode 1.x selbst, wenn sein Dienst startet. CodeNomad konvertiert sie nicht. Schlägt die Installation von OpenCode 2 nach dem Entfernen fehl, können Sie es hier erneut versuchen.",
"settings.opencode.setup.legacy_replacement_required": "Bestätigen Sie das Ersetzen von OpenCode 1.x, um fortzufahren.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x wurde entfernt, aber OpenCode 2 konnte nicht installiert werden. Ihr OpenCode-Datenordner ist unverändert. Prüfen Sie die Verbindung und wiederholen Sie die Installation.",
"settings.opencode.setup.legacy_partially_removed": "Das Entfernen von OpenCode 1.x wurde unterbrochen; es ist möglicherweise nur teilweise entfernt. Schließen Sie laufende OpenCode-1.x-Prozesse, führen Sie „npm uninstall -g opencode-ai“ in einem Terminal aus und wiederholen Sie dann die Installation.",
"settings.opencode.update.available": "OpenCode {version} ist verfügbar.",
"settings.opencode.setup.manage": "OpenCode verwalten…",
"settings.opencode.setup.connected": "OpenCode ist verbunden.",

View file

@ -337,7 +337,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Add",
"opencodeBinarySelector.actions.browse": "Browse for Binary…",
"opencodeBinarySelector.actions.removeTitle": "Remove binary",
"opencodeBinarySelector.badge.systemPath": "Prefer PATH, then the user npm installation",
"opencodeBinarySelector.badge.systemPath": "Prefer PATH unless it holds an older OpenCode, then the user npm OpenCode 2",
"opencodeBinarySelector.status.checkingVersions": "Checking versions…",
"opencodeBinarySelector.status.checking": "Checking…",
"opencodeBinarySelector.dialog.title": "Select OpenCode Binary",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "User npm installation found outside the current PATH.",
"settings.opencode.setup.sharedInstall": "Install for the user and configure PATH",
"settings.opencode.setup.sharedInstallDescription": "Uses a standard npm installation shared with other clients. Open a new terminal after installation.",
"settings.opencode.setup.source.managed": "CodeNomad uses its OpenCode 2 installation in your user npm folder because an older OpenCode comes first on PATH.",
"settings.opencode.setup.foreign.found": "OpenCode {version} was found at {path}. CodeNomad requires OpenCode {minimum} or later and does not modify this installation.",
"settings.opencode.setup.foreign.unusable": "The OpenCode found at {path} could not be started. CodeNomad does not modify this installation.",
"settings.opencode.setup.foreign.winget": "This older version was installed with winget.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 is installed for CodeNomad in your user npm folder, alongside the older version.",
"settings.opencode.setup.foreign.terminal": "Depending on PATH order, the opencode command in a terminal may still start the older version.",
"settings.opencode.setup.foreign.wingetUninstall": "If you no longer need it, you can remove it yourself with:",
"settings.opencode.setup.foreign.install": "Install OpenCode 2 for CodeNomad",
"settings.opencode.setup.legacy.action": "Replace OpenCode {version} with OpenCode 2",
"settings.opencode.setup.legacy.title": "Replace OpenCode {version}?",
"settings.opencode.setup.legacy.description": "OpenCode {version} was installed with npm ({path}). Replacing it removes the npm package opencode-ai and installs OpenCode 2 in the same npm folder.",
"settings.opencode.setup.legacy.confirm": "The npm package opencode-ai {version} will be removed and OpenCode 2 (@opencode/cli) installed in its place. The opencode command from this npm folder will then start OpenCode 2; OpenCode {version} will no longer be available from it. Your OpenCode data folder is not deleted: OpenCode 2 uses the same folder and imports OpenCode 1.x conversations itself when its service starts. CodeNomad does not convert them. If installing OpenCode 2 fails after the removal, you can retry from this screen.",
"settings.opencode.setup.legacy_replacement_required": "Confirm the replacement of OpenCode 1.x to continue.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x was removed, but OpenCode 2 could not be installed. Your OpenCode data folder is unchanged. Check the connection and retry the installation.",
"settings.opencode.setup.legacy_partially_removed": "Removing OpenCode 1.x was interrupted and it may be only partly removed. Close any running OpenCode 1.x, run “npm uninstall -g opencode-ai” in a terminal, then retry the installation.",
"settings.opencode.update.available": "OpenCode {version} is available.",
"settings.opencode.setup.manage": "Manage OpenCode…",
"settings.opencode.setup.connected": "OpenCode is connected.",

View file

@ -338,7 +338,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Agregar",
"opencodeBinarySelector.actions.browse": "Explorar binario…",
"opencodeBinarySelector.actions.removeTitle": "Quitar binario",
"opencodeBinarySelector.badge.systemPath": "Primero PATH, luego la instalación npm del usuario",
"opencodeBinarySelector.badge.systemPath": "Primero PATH salvo que contenga un OpenCode más antiguo; luego OpenCode 2 de la instalación npm del usuario",
"opencodeBinarySelector.status.checkingVersions": "Comprobando versiones…",
"opencodeBinarySelector.status.checking": "Comprobando…",
"opencodeBinarySelector.dialog.title": "Seleccionar binario de OpenCode",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "Instalación npm del usuario encontrada fuera del PATH actual.",
"settings.opencode.setup.sharedInstall": "Instalar para el usuario y configurar PATH",
"settings.opencode.setup.sharedInstallDescription": "Usa una instalación npm estándar compartida con otros clientes. Abre un terminal nuevo después de instalar.",
"settings.opencode.setup.source.managed": "CodeNomad usa su instalación de OpenCode 2 en tu carpeta npm de usuario porque un OpenCode más antiguo aparece primero en el PATH.",
"settings.opencode.setup.foreign.found": "Se encontró OpenCode {version} en {path}. CodeNomad necesita OpenCode {minimum} o posterior y no modifica esta instalación.",
"settings.opencode.setup.foreign.unusable": "No se pudo iniciar el OpenCode encontrado en {path}. CodeNomad no modifica esta instalación.",
"settings.opencode.setup.foreign.winget": "Esta versión antigua se instaló con winget.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 se instala para CodeNomad en tu carpeta npm de usuario, junto a la versión antigua.",
"settings.opencode.setup.foreign.terminal": "Según el orden del PATH, el comando opencode de un terminal puede seguir iniciando la versión antigua.",
"settings.opencode.setup.foreign.wingetUninstall": "Si ya no la necesitas, puedes eliminarla tú mismo con:",
"settings.opencode.setup.foreign.install": "Instalar OpenCode 2 para CodeNomad",
"settings.opencode.setup.legacy.action": "Reemplazar OpenCode {version} por OpenCode 2",
"settings.opencode.setup.legacy.title": "¿Reemplazar OpenCode {version}?",
"settings.opencode.setup.legacy.description": "OpenCode {version} se instaló con npm ({path}). Reemplazarlo elimina el paquete npm opencode-ai e instala OpenCode 2 en la misma carpeta npm.",
"settings.opencode.setup.legacy.confirm": "Se eliminará el paquete npm opencode-ai {version} y se instalará OpenCode 2 (@opencode/cli) en su lugar. El comando opencode de esta carpeta npm iniciará entonces OpenCode 2; OpenCode {version} ya no estará disponible desde él. Tu carpeta de datos de OpenCode no se elimina: OpenCode 2 usa la misma carpeta e importa por sí mismo las conversaciones de OpenCode 1.x cuando se inicia su servicio. CodeNomad no las convierte. Si la instalación de OpenCode 2 falla después de la eliminación, puedes reintentarla desde esta pantalla.",
"settings.opencode.setup.legacy_replacement_required": "Confirma el reemplazo de OpenCode 1.x para continuar.",
"settings.opencode.setup.legacy_removed_install_failed": "Se eliminó OpenCode 1.x, pero no se pudo instalar OpenCode 2. Tu carpeta de datos de OpenCode no ha cambiado. Comprueba la conexión y reintenta la instalación.",
"settings.opencode.setup.legacy_partially_removed": "La eliminación de OpenCode 1.x se interrumpió y puede haberse eliminado solo en parte. Cierra cualquier OpenCode 1.x en ejecución, ejecuta «npm uninstall -g opencode-ai» en un terminal y vuelve a intentar la instalación.",
"settings.opencode.update.available": "OpenCode {version} está disponible.",
"settings.opencode.setup.manage": "Gestionar OpenCode…",
"settings.opencode.setup.connected": "OpenCode está conectado.",

View file

@ -338,7 +338,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Ajouter",
"opencodeBinarySelector.actions.browse": "Parcourir le binaire…",
"opencodeBinarySelector.actions.removeTitle": "Supprimer le binaire",
"opencodeBinarySelector.badge.systemPath": "PATH prioritaire, puis installation npm de l’utilisateur",
"opencodeBinarySelector.badge.systemPath": "PATH prioritaire sauf s’il contient un OpenCode plus ancien, puis OpenCode 2 de l’installation npm de l’utilisateur",
"opencodeBinarySelector.status.checkingVersions": "Vérification des versions…",
"opencodeBinarySelector.status.checking": "Vérification…",
"opencodeBinarySelector.dialog.title": "Sélectionner le binaire OpenCode",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "Installation npm utilisateur trouvée hors du PATH actuel.",
"settings.opencode.setup.sharedInstall": "Installer pour l’utilisateur et configurer le PATH",
"settings.opencode.setup.sharedInstallDescription": "Utilise une installation npm standard partagée avec les autres clients. Ouvrez un nouveau terminal après l’installation.",
"settings.opencode.setup.source.managed": "CodeNomad utilise son installation d’OpenCode 2 dans votre dossier npm utilisateur, car un OpenCode plus ancien apparaît en premier dans le PATH.",
"settings.opencode.setup.foreign.found": "OpenCode {version} a été trouvé dans {path}. CodeNomad nécessite OpenCode {minimum} ou ultérieur et ne modifie pas cette installation.",
"settings.opencode.setup.foreign.unusable": "L’OpenCode trouvé dans {path} n’a pas pu démarrer. CodeNomad ne modifie pas cette installation.",
"settings.opencode.setup.foreign.winget": "Cette ancienne version a été installée avec winget.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 est installé pour CodeNomad dans votre dossier npm utilisateur, à côté de l’ancienne version.",
"settings.opencode.setup.foreign.terminal": "Selon l’ordre du PATH, la commande opencode d’un terminal peut encore lancer l’ancienne version.",
"settings.opencode.setup.foreign.wingetUninstall": "Si vous n’en avez plus besoin, vous pouvez la supprimer vous-même avec :",
"settings.opencode.setup.foreign.install": "Installer OpenCode 2 pour CodeNomad",
"settings.opencode.setup.legacy.action": "Remplacer OpenCode {version} par OpenCode 2",
"settings.opencode.setup.legacy.title": "Remplacer OpenCode {version} ?",
"settings.opencode.setup.legacy.description": "OpenCode {version} a été installé avec npm ({path}). Le remplacer supprime le paquet npm opencode-ai et installe OpenCode 2 dans le même dossier npm.",
"settings.opencode.setup.legacy.confirm": "Le paquet npm opencode-ai {version} sera supprimé et OpenCode 2 (@opencode/cli) installé à sa place. La commande opencode de ce dossier npm lancera alors OpenCode 2 ; OpenCode {version} n’y sera plus disponible. Votre dossier de données OpenCode n’est pas supprimé : OpenCode 2 utilise le même dossier et importe lui-même les conversations d’OpenCode 1.x au démarrage de son service. CodeNomad ne les convertit pas. Si l’installation d’OpenCode 2 échoue après la suppression, vous pourrez réessayer depuis cet écran.",
"settings.opencode.setup.legacy_replacement_required": "Confirmez le remplacement d’OpenCode 1.x pour continuer.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x a été supprimé, mais OpenCode 2 n’a pas pu être installé. Votre dossier de données OpenCode est inchangé. Vérifiez la connexion et relancez l’installation.",
"settings.opencode.setup.legacy_partially_removed": "La suppression d’OpenCode 1.x a été interrompue et il n’est peut-être que partiellement supprimé. Fermez tout OpenCode 1.x en cours d’exécution, exécutez « npm uninstall -g opencode-ai » dans un terminal, puis relancez l’installation.",
"settings.opencode.update.available": "OpenCode {version} est disponible.",
"settings.opencode.setup.manage": "Gérer OpenCode…",
"settings.opencode.setup.connected": "OpenCode est connecté.",

View file

@ -337,7 +337,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "הוסף",
"opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…",
"opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי",
"opencodeBinarySelector.badge.systemPath": "עדיפות ל-PATH, אחריו התקנת npm של המשתמש",
"opencodeBinarySelector.badge.systemPath": "עדיפות ל-PATH אלא אם יש בו OpenCode ישן יותר, ואחריו OpenCode 2 מהתקנת ה-npm של המשתמש",
"opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…",
"opencodeBinarySelector.status.checking": "בודק…",
"opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "נמצאה התקנת npm של המשתמש מחוץ ל-PATH הנוכחי.",
"settings.opencode.setup.sharedInstall": "התקנה עבור המשתמש והגדרת PATH",
"settings.opencode.setup.sharedInstallDescription": "משתמש בהתקנת npm רגילה המשותפת ללקוחות אחרים. יש לפתוח מסוף חדש לאחר ההתקנה.",
"settings.opencode.setup.source.managed": "CodeNomad משתמש בהתקנת OpenCode 2 שלו בתיקיית ה-npm של המשתמש, כי גרסת OpenCode ישנה יותר מופיעה ראשונה ב-PATH.",
"settings.opencode.setup.foreign.found": "OpenCode {version} נמצא ב-{path}. CodeNomad דורש OpenCode {minimum} ומעלה ואינו משנה התקנה זו.",
"settings.opencode.setup.foreign.unusable": "לא ניתן היה להפעיל את OpenCode שנמצא ב-{path}. CodeNomad אינו משנה התקנה זו.",
"settings.opencode.setup.foreign.winget": "הגרסה הישנה הזו הותקנה באמצעות winget.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 מותקן עבור CodeNomad בתיקיית ה-npm של המשתמש, לצד הגרסה הישנה.",
"settings.opencode.setup.foreign.terminal": "בהתאם לסדר ה-PATH, הפקודה opencode במסוף עשויה עדיין להפעיל את הגרסה הישנה.",
"settings.opencode.setup.foreign.wingetUninstall": "אם אינך צריך אותה עוד, ניתן להסיר אותה בעצמך באמצעות:",
"settings.opencode.setup.foreign.install": "התקנת OpenCode 2 עבור CodeNomad",
"settings.opencode.setup.legacy.action": "החלפת OpenCode {version} ב-OpenCode 2",
"settings.opencode.setup.legacy.title": "להחליף את OpenCode {version}?",
"settings.opencode.setup.legacy.description": "OpenCode {version} הותקן באמצעות npm ({path}). ההחלפה מסירה את חבילת ה-npm opencode-ai ומתקינה את OpenCode 2 באותה תיקיית npm.",
"settings.opencode.setup.legacy.confirm": "חבילת ה-npm opencode-ai {version} תוסר ו-OpenCode 2 (@opencode/cli) יותקן במקומה. לאחר מכן הפקודה opencode מתיקיית npm זו תפעיל את OpenCode 2; OpenCode {version} לא יהיה זמין עוד דרכה. תיקיית הנתונים של OpenCode אינה נמחקת: OpenCode 2 משתמש באותה תיקייה ומייבא בעצמו שיחות של OpenCode 1.x כשהשירות שלו מופעל. CodeNomad אינו ממיר אותן. אם התקנת OpenCode 2 נכשלת לאחר ההסרה, ניתן לנסות שוב ממסך זה.",
"settings.opencode.setup.legacy_replacement_required": "יש לאשר את החלפת OpenCode 1.x כדי להמשיך.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x הוסר, אך לא ניתן היה להתקין את OpenCode 2. תיקיית הנתונים של OpenCode לא השתנתה. יש לבדוק את החיבור ולנסות להתקין שוב.",
"settings.opencode.setup.legacy_partially_removed": "הסרת OpenCode 1.x נקטעה וייתכן שהוסר רק באופן חלקי. יש לסגור כל OpenCode 1.x פועל, להריץ במסוף „npm uninstall -g opencode-ai” ולאחר מכן לנסות שוב את ההתקנה.",
"settings.opencode.update.available": "OpenCode {version} זמין.",
"settings.opencode.setup.manage": "ניהול OpenCode…",
"settings.opencode.setup.connected": "OpenCode מחובר.",

View file

@ -338,7 +338,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "追加",
"opencodeBinarySelector.actions.browse": "バイナリを参照…",
"opencodeBinarySelector.actions.removeTitle": "バイナリを削除",
"opencodeBinarySelector.badge.systemPath": "PATH、ユーザーの npm インストールの順で優先",
"opencodeBinarySelector.badge.systemPath": "古い OpenCode でない限り PATH を優先し、次にユーザー npm の OpenCode 2",
"opencodeBinarySelector.status.checkingVersions": "バージョンを確認中…",
"opencodeBinarySelector.status.checking": "確認中…",
"opencodeBinarySelector.dialog.title": "OpenCode バイナリを選択",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "現在の PATH 外にユーザーの npm インストールが見つかりました。",
"settings.opencode.setup.sharedInstall": "ユーザー用にインストールして PATH を設定",
"settings.opencode.setup.sharedInstallDescription": "他のクライアントと共有する標準の npm インストールを使用します。インストール後に新しいターミナルを開いてください。",
"settings.opencode.setup.source.managed": "PATH で古い OpenCode が先に見つかるため、CodeNomad はユーザーの npm フォルダーにある OpenCode 2 を使用します。",
"settings.opencode.setup.foreign.found": "{path} で OpenCode {version} が見つかりました。CodeNomad には OpenCode {minimum} 以降が必要です。このインストールは変更しません。",
"settings.opencode.setup.foreign.unusable": "{path} で見つかった OpenCode を起動できませんでした。CodeNomad はこのインストールを変更しません。",
"settings.opencode.setup.foreign.winget": "この古いバージョンは winget でインストールされています。",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 は CodeNomad 用に、古いバージョンと並べてユーザーの npm フォルダーへインストールされます。",
"settings.opencode.setup.foreign.terminal": "PATH の順序によっては、ターミナルの opencode コマンドが引き続き古いバージョンを起動する場合があります。",
"settings.opencode.setup.foreign.wingetUninstall": "不要であれば、次のコマンドでご自身で削除できます:",
"settings.opencode.setup.foreign.install": "CodeNomad 用に OpenCode 2 をインストール",
"settings.opencode.setup.legacy.action": "OpenCode {version} を OpenCode 2 に置き換える",
"settings.opencode.setup.legacy.title": "OpenCode {version} を置き換えますか?",
"settings.opencode.setup.legacy.description": "OpenCode {version} は npm でインストールされています ({path})。置き換えると npm パッケージ opencode-ai を削除し、同じ npm フォルダーに OpenCode 2 をインストールします。",
"settings.opencode.setup.legacy.confirm": "npm パッケージ opencode-ai {version} を削除し、代わりに OpenCode 2 (@opencode/cli) をインストールします。その後、この npm フォルダーの opencode コマンドは OpenCode 2 を起動し、OpenCode {version} は使用できなくなります。OpenCode のデータフォルダーは削除されません。OpenCode 2 は同じフォルダーを使用し、サービス起動時に OpenCode 1.x の会話を自身でインポートします。CodeNomad は変換しません。削除後に OpenCode 2 のインストールに失敗した場合は、この画面から再試行できます。",
"settings.opencode.setup.legacy_replacement_required": "続行するには OpenCode 1.x の置き換えを確認してください。",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x は削除されましたが、OpenCode 2 をインストールできませんでした。OpenCode のデータフォルダーは変更されていません。接続を確認してインストールを再試行してください。",
"settings.opencode.setup.legacy_partially_removed": "OpenCode 1.x の削除が中断され、一部だけ削除された可能性があります。実行中の OpenCode 1.x を終了し、ターミナルで「npm uninstall -g opencode-ai」を実行してから、インストールを再試行してください。",
"settings.opencode.update.available": "OpenCode {version} が利用可能です。",
"settings.opencode.setup.manage": "OpenCode を管理…",
"settings.opencode.setup.connected": "OpenCode に接続しています。",

View file

@ -337,7 +337,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "थप्नुहोस्",
"opencodeBinarySelector.actions.browse": "बाइनरीको लागि ब्राउज गर्नुहोस्...",
"opencodeBinarySelector.actions.removeTitle": "बाइनरी हटाउनुहोस्",
"opencodeBinarySelector.badge.systemPath": "पहिले PATH, त्यसपछि प्रयोगकर्ताको npm स्थापना",
"opencodeBinarySelector.badge.systemPath": "पुरानो OpenCode नभएसम्म PATH लाई प्राथमिकता, त्यसपछि प्रयोगकर्ता npm को OpenCode 2",
"opencodeBinarySelector.status.checkingVersions": "संस्करणहरू जाँच गर्दै...",
"opencodeBinarySelector.status.checking": "जाँच गर्दै...",
"opencodeBinarySelector.dialog.title": "OpenCode बाइनरी चयन गर्नुहोस्",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "हालको PATH बाहिर प्रयोगकर्ताको npm स्थापना भेटियो।",
"settings.opencode.setup.sharedInstall": "प्रयोगकर्ताका लागि स्थापना गरी PATH मिलाउनुहोस्",
"settings.opencode.setup.sharedInstallDescription": "अन्य क्लाइन्टसँग साझा हुने मानक npm स्थापना प्रयोग गर्छ। स्थापनापछि नयाँ टर्मिनल खोल्नुहोस्।",
"settings.opencode.setup.source.managed": "PATH मा पुरानो OpenCode पहिले भेटिने भएकाले CodeNomad ले तपाईंको प्रयोगकर्ता npm फोल्डरमा रहेको OpenCode 2 स्थापना प्रयोग गर्छ।",
"settings.opencode.setup.foreign.found": "{path} मा OpenCode {version} भेटियो। CodeNomad लाई OpenCode {minimum} वा पछिल्लो चाहिन्छ र यसले यो स्थापना परिवर्तन गर्दैन।",
"settings.opencode.setup.foreign.unusable": "{path} मा भेटिएको OpenCode सुरु गर्न सकिएन। CodeNomad ले यो स्थापना परिवर्तन गर्दैन।",
"settings.opencode.setup.foreign.winget": "यो पुरानो संस्करण winget बाट स्थापना गरिएको हो।",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 CodeNomad का लागि तपाईंको प्रयोगकर्ता npm फोल्डरमा पुरानो संस्करणसँगै स्थापना हुन्छ।",
"settings.opencode.setup.foreign.terminal": "PATH को क्रमअनुसार टर्मिनलको opencode आदेशले अझै पुरानो संस्करण सुरु गर्न सक्छ।",
"settings.opencode.setup.foreign.wingetUninstall": "यदि अब आवश्यक छैन भने, तपाईं आफैं यो आदेशले हटाउन सक्नुहुन्छ:",
"settings.opencode.setup.foreign.install": "CodeNomad का लागि OpenCode 2 स्थापना गर्नुहोस्",
"settings.opencode.setup.legacy.action": "OpenCode {version} लाई OpenCode 2 ले प्रतिस्थापन गर्नुहोस्",
"settings.opencode.setup.legacy.title": "OpenCode {version} प्रतिस्थापन गर्ने?",
"settings.opencode.setup.legacy.description": "OpenCode {version} npm बाट स्थापना गरिएको हो ({path})। प्रतिस्थापन गर्दा npm प्याकेज opencode-ai हटाइन्छ र सोही npm फोल्डरमा OpenCode 2 स्थापना हुन्छ।",
"settings.opencode.setup.legacy.confirm": "npm प्याकेज opencode-ai {version} हटाइनेछ र यसको ठाउँमा OpenCode 2 (@opencode/cli) स्थापना हुनेछ। त्यसपछि यो npm फोल्डरको opencode आदेशले OpenCode 2 सुरु गर्नेछ; OpenCode {version} त्यसबाट उपलब्ध हुने छैन। तपाईंको OpenCode डेटा फोल्डर मेटाइँदैन: OpenCode 2 ले सोही फोल्डर प्रयोग गर्छ र आफ्नो सेवा सुरु हुँदा OpenCode 1.x का कुराकानीहरू आफैं आयात गर्छ। CodeNomad ले तिनलाई रूपान्तरण गर्दैन। हटाएपछि OpenCode 2 स्थापना असफल भएमा यही स्क्रिनबाट फेरि प्रयास गर्न सक्नुहुन्छ।",
"settings.opencode.setup.legacy_replacement_required": "जारी राख्न OpenCode 1.x को प्रतिस्थापन पुष्टि गर्नुहोस्।",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x हटाइयो, तर OpenCode 2 स्थापना गर्न सकिएन। तपाईंको OpenCode डेटा फोल्डर अपरिवर्तित छ। जडान जाँच गरेर स्थापना फेरि प्रयास गर्नुहोस्।",
"settings.opencode.setup.legacy_partially_removed": "OpenCode 1.x हटाउने काम बीचमै रोकियो र यो आंशिक रूपमा मात्र हटेको हुन सक्छ। चलिरहेको OpenCode 1.x बन्द गर्नुहोस्, टर्मिनलमा “npm uninstall -g opencode-ai” चलाउनुहोस्, अनि स्थापना फेरि प्रयास गर्नुहोस्।",
"settings.opencode.update.available": "OpenCode {version} उपलब्ध छ।",
"settings.opencode.setup.manage": "OpenCode व्यवस्थापन गर्नुहोस्…",
"settings.opencode.setup.connected": "OpenCode जडान भएको छ।",

View file

@ -338,7 +338,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Добавить",
"opencodeBinarySelector.actions.browse": "Выбрать бинарник…",
"opencodeBinarySelector.actions.removeTitle": "Удалить бинарник",
"opencodeBinarySelector.badge.systemPath": "Сначала PATH, затем пользовательская установка npm",
"opencodeBinarySelector.badge.systemPath": "Сначала PATH, если там не более старый OpenCode, затем OpenCode 2 из пользовательской установки npm",
"opencodeBinarySelector.status.checkingVersions": "Проверка версий…",
"opencodeBinarySelector.status.checking": "Проверка…",
"opencodeBinarySelector.dialog.title": "Выберите бинарник OpenCode",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "Пользовательская установка npm найдена вне текущего PATH.",
"settings.opencode.setup.sharedInstall": "Установить для пользователя и настроить PATH",
"settings.opencode.setup.sharedInstallDescription": "Используется стандартная установка npm, общая с другими клиентами. После установки откройте новый терминал.",
"settings.opencode.setup.source.managed": "CodeNomad использует свою установку OpenCode 2 в пользовательской папке npm, потому что в PATH первым находится более старый OpenCode.",
"settings.opencode.setup.foreign.found": "OpenCode {version} найден в {path}. CodeNomad требует OpenCode {minimum} или новее и не изменяет эту установку.",
"settings.opencode.setup.foreign.unusable": "Не удалось запустить OpenCode, найденный в {path}. CodeNomad не изменяет эту установку.",
"settings.opencode.setup.foreign.winget": "Эта старая версия установлена через winget.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 устанавливается для CodeNomad в пользовательскую папку npm рядом со старой версией.",
"settings.opencode.setup.foreign.terminal": "В зависимости от порядка PATH команда opencode в терминале может по-прежнему запускать старую версию.",
"settings.opencode.setup.foreign.wingetUninstall": "Если она больше не нужна, вы можете удалить её самостоятельно командой:",
"settings.opencode.setup.foreign.install": "Установить OpenCode 2 для CodeNomad",
"settings.opencode.setup.legacy.action": "Заменить OpenCode {version} на OpenCode 2",
"settings.opencode.setup.legacy.title": "Заменить OpenCode {version}?",
"settings.opencode.setup.legacy.description": "OpenCode {version} установлен через npm ({path}). Замена удаляет пакет npm opencode-ai и устанавливает OpenCode 2 в ту же папку npm.",
"settings.opencode.setup.legacy.confirm": "Пакет npm opencode-ai {version} будет удалён, а на его место установлен OpenCode 2 (@opencode/cli). После этого команда opencode из этой папки npm будет запускать OpenCode 2; OpenCode {version} через неё будет недоступен. Папка данных OpenCode не удаляется: OpenCode 2 использует ту же папку и сам импортирует беседы OpenCode 1.x при запуске своей службы. CodeNomad их не преобразует. Если установка OpenCode 2 после удаления завершится ошибкой, её можно повторить на этом экране.",
"settings.opencode.setup.legacy_replacement_required": "Подтвердите замену OpenCode 1.x, чтобы продолжить.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x удалён, но установить OpenCode 2 не удалось. Папка данных OpenCode не изменена. Проверьте подключение и повторите установку.",
"settings.opencode.setup.legacy_partially_removed": "Удаление OpenCode 1.x было прервано, и он мог быть удалён лишь частично. Закройте запущенный OpenCode 1.x, выполните «npm uninstall -g opencode-ai» в терминале и повторите установку.",
"settings.opencode.update.available": "Доступна версия OpenCode {version}.",
"settings.opencode.setup.manage": "Управление OpenCode…",
"settings.opencode.setup.connected": "OpenCode подключён.",

View file

@ -316,7 +316,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "Ekle",
"opencodeBinarySelector.actions.browse": "Binary için gözat…",
"opencodeBinarySelector.actions.removeTitle": "Binary'yi kaldır",
"opencodeBinarySelector.badge.systemPath": "Önce PATH, sonra kullanıcının npm kurulumu",
"opencodeBinarySelector.badge.systemPath": "Daha eski bir OpenCode içermedikçe önce PATH, sonra kullanıcının npm OpenCode 2 kurulumu",
"opencodeBinarySelector.status.checkingVersions": "Sürümler kontrol ediliyor…",
"opencodeBinarySelector.status.checking": "Kontrol ediliyor…",
"opencodeBinarySelector.dialog.title": "OpenCode Binary Seç",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "Geçerli PATH dışında kullanıcı npm kurulumu bulundu.",
"settings.opencode.setup.sharedInstall": "Kullanıcı için kur ve PATH'i yapılandır",
"settings.opencode.setup.sharedInstallDescription": "Diğer istemcilerle paylaşılan standart npm kurulumunu kullanır. Kurulumdan sonra yeni terminal açın.",
"settings.opencode.setup.source.managed": "PATH içinde daha eski bir OpenCode önce bulunduğu için CodeNomad, kullanıcı npm klasörünüzdeki OpenCode 2 kurulumunu kullanır.",
"settings.opencode.setup.foreign.found": "OpenCode {version}, {path} konumunda bulundu. CodeNomad OpenCode {minimum} veya üstünü gerektirir ve bu kurulumu değiştirmez.",
"settings.opencode.setup.foreign.unusable": "{path} konumunda bulunan OpenCode başlatılamadı. CodeNomad bu kurulumu değiştirmez.",
"settings.opencode.setup.foreign.winget": "Bu eski sürüm winget ile kurulmuş.",
"settings.opencode.setup.foreign.alongside": "OpenCode 2, CodeNomad için kullanıcı npm klasörünüze eski sürümün yanına kurulur.",
"settings.opencode.setup.foreign.terminal": "PATH sırasına bağlı olarak terminaldeki opencode komutu hâlâ eski sürümü başlatabilir.",
"settings.opencode.setup.foreign.wingetUninstall": "Artık gerekmiyorsa şu komutla kendiniz kaldırabilirsiniz:",
"settings.opencode.setup.foreign.install": "CodeNomad için OpenCode 2 kur",
"settings.opencode.setup.legacy.action": "OpenCode {version} sürümünü OpenCode 2 ile değiştir",
"settings.opencode.setup.legacy.title": "OpenCode {version} değiştirilsin mi?",
"settings.opencode.setup.legacy.description": "OpenCode {version} npm ile kurulmuş ({path}). Değiştirmek, opencode-ai npm paketini kaldırır ve OpenCode 2'yi aynı npm klasörüne kurar.",
"settings.opencode.setup.legacy.confirm": "opencode-ai {version} npm paketi kaldırılacak ve yerine OpenCode 2 (@opencode/cli) kurulacak. Ardından bu npm klasöründeki opencode komutu OpenCode 2'yi başlatır; OpenCode {version} artık bu komutla kullanılamaz. OpenCode veri klasörünüz silinmez: OpenCode 2 aynı klasörü kullanır ve hizmeti başladığında OpenCode 1.x konuşmalarını kendisi içe aktarır. CodeNomad bunları dönüştürmez. Kaldırmadan sonra OpenCode 2 kurulumu başarısız olursa bu ekrandan yeniden deneyebilirsiniz.",
"settings.opencode.setup.legacy_replacement_required": "Devam etmek için OpenCode 1.x değişimini onaylayın.",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x kaldırıldı ancak OpenCode 2 kurulamadı. OpenCode veri klasörünüz değişmedi. Bağlantıyı kontrol edip kurulumu yeniden deneyin.",
"settings.opencode.setup.legacy_partially_removed": "OpenCode 1.x kaldırma işlemi yarıda kaldı ve yalnızca kısmen kaldırılmış olabilir. Çalışan OpenCode 1.x'i kapatın, bir terminalde “npm uninstall -g opencode-ai” komutunu çalıştırın ve ardından kurulumu yeniden deneyin.",
"settings.opencode.update.available": "OpenCode {version} kullanılabilir.",
"settings.opencode.setup.manage": "OpenCode'u yönet…",
"settings.opencode.setup.connected": "OpenCode bağlı.",

View file

@ -338,7 +338,7 @@ export const instanceMessages = {
"opencodeBinarySelector.actions.add": "添加",
"opencodeBinarySelector.actions.browse": "浏览可执行文件…",
"opencodeBinarySelector.actions.removeTitle": "移除可执行文件",
"opencodeBinarySelector.badge.systemPath": "优先使用 PATH,其次为用户 npm 安装",
"opencodeBinarySelector.badge.systemPath": "优先使用 PATH(除非其中是较旧的 OpenCode),其次是用户 npm 中的 OpenCode 2",
"opencodeBinarySelector.status.checkingVersions": "正在检查版本…",
"opencodeBinarySelector.status.checking": "正在检查…",
"opencodeBinarySelector.dialog.title": "选择 OpenCode 可执行文件",

View file

@ -62,6 +62,21 @@ export const settingsMessages = {
"settings.opencode.setup.source.user": "已在当前 PATH 之外找到用户 npm 安装。",
"settings.opencode.setup.sharedInstall": "为用户安装并配置 PATH",
"settings.opencode.setup.sharedInstallDescription": "使用与其他客户端共享的标准 npm 安装。安装后请打开新终端。",
"settings.opencode.setup.source.managed": "由于 PATH 中较旧的 OpenCode 排在前面,CodeNomad 使用你用户 npm 文件夹中的 OpenCode 2 安装。",
"settings.opencode.setup.foreign.found": "在 {path} 找到 OpenCode {version}。CodeNomad 需要 OpenCode {minimum} 或更高版本,不会修改此安装。",
"settings.opencode.setup.foreign.unusable": "无法启动在 {path} 找到的 OpenCode。CodeNomad 不会修改此安装。",
"settings.opencode.setup.foreign.winget": "此旧版本是通过 winget 安装的。",
"settings.opencode.setup.foreign.alongside": "OpenCode 2 将为 CodeNomad 安装到你的用户 npm 文件夹中,与旧版本并存。",
"settings.opencode.setup.foreign.terminal": "根据 PATH 顺序,终端中的 opencode 命令可能仍会启动旧版本。",
"settings.opencode.setup.foreign.wingetUninstall": "如果不再需要,可以自行使用以下命令删除:",
"settings.opencode.setup.foreign.install": "为 CodeNomad 安装 OpenCode 2",
"settings.opencode.setup.legacy.action": "用 OpenCode 2 替换 OpenCode {version}",
"settings.opencode.setup.legacy.title": "替换 OpenCode {version}?",
"settings.opencode.setup.legacy.description": "OpenCode {version} 是通过 npm 安装的({path})。替换会删除 npm 包 opencode-ai,并在同一 npm 文件夹中安装 OpenCode 2。",
"settings.opencode.setup.legacy.confirm": "将删除 npm 包 opencode-ai {version},并在原处安装 OpenCode 2(@opencode/cli)。之后,此 npm 文件夹中的 opencode 命令将启动 OpenCode 2,无法再通过它使用 OpenCode {version}。你的 OpenCode 数据文件夹不会被删除:OpenCode 2 使用同一文件夹,并在其服务启动时自行导入 OpenCode 1.x 的对话。CodeNomad 不会转换它们。如果删除后安装 OpenCode 2 失败,可以在此界面重试。",
"settings.opencode.setup.legacy_replacement_required": "请确认替换 OpenCode 1.x 以继续。",
"settings.opencode.setup.legacy_removed_install_failed": "OpenCode 1.x 已删除,但无法安装 OpenCode 2。你的 OpenCode 数据文件夹未改变。请检查网络连接并重试安装。",
"settings.opencode.setup.legacy_partially_removed": "删除 OpenCode 1.x 的过程被中断,可能只删除了一部分。请关闭正在运行的 OpenCode 1.x,在终端中运行“npm uninstall -g opencode-ai”,然后重试安装。",
"settings.opencode.update.available": "OpenCode {version} 已可用。",
"settings.opencode.setup.manage": "管理 OpenCode…",
"settings.opencode.setup.connected": "OpenCode 已连接。",

View file

@ -7,7 +7,10 @@ export const [openCodeSetupOpen, setOpenCodeSetupOpen] = createSignal(false)
export const [openCodeSetupBusy, setOpenCodeSetupBusy] = createSignal(false)
export const [openCodeSetupError, setOpenCodeSetupError] = createSignal(false)
export const [openCodeSetupCheckError, setOpenCodeSetupCheckError] = createSignal(false)
export const [openCodeInstallationError, setOpenCodeInstallationError] = createSignal<"installation_busy" | "installation_in_use">()
const INSTALLATION_ERRORS = ["installation_busy", "installation_in_use", "legacy_replacement_required", "legacy_removed_install_failed",
"legacy_partially_removed"] as const
type OpenCodeInstallationError = typeof INSTALLATION_ERRORS[number]
export const [openCodeInstallationError, setOpenCodeInstallationError] = createSignal<OpenCodeInstallationError>()
export type OpenCodeSetupAction = "install" | "start" | "restart" | "reload"
export const [openCodeSetupAction, setOpenCodeSetupAction] = createSignal<OpenCodeSetupAction>()
export const [openCodeSetupPhase, setOpenCodeSetupPhase] = createSignal<OpenCodeSetupAction | "check">()
@ -100,7 +103,8 @@ export function refreshOpenCodeSetup(afterMutation = false, announce = false): P
do {
request.again = false
try {
const status = await serverApi.fetchOpenCodeUpdateStatus()
// Only the explicit Check action asks the server to re-probe installations.
const status = await serverApi.fetchOpenCodeUpdateStatus({ recheck: request.announce })
if (epoch !== generation) return
setOpenCodeSetupStatus(status)
setOpenCodeSetupCheckError(false)
@ -121,7 +125,7 @@ export function refreshOpenCodeSetup(afterMutation = false, announce = false): P
return request.promise
}
export async function runOpenCodeSetup(action: OpenCodeSetupAction, options: { resumeWorkspace?: boolean } = {}): Promise<OpenCodeUpdateStatus | undefined> {
export async function runOpenCodeSetup(action: OpenCodeSetupAction, options: { resumeWorkspace?: boolean; replaceLegacy?: { path: string; version: string } } = {}): Promise<OpenCodeUpdateStatus | undefined> {
if (openCodeSetupBusy()) return
const epoch = ++generation
pending = undefined
@ -133,7 +137,7 @@ export async function runOpenCodeSetup(action: OpenCodeSetupAction, options: { r
setOpenCodeSetupError(false)
setOpenCodeInstallationError(undefined)
try {
if (action === "install") await serverApi.updateOpenCode()
if (action === "install") await serverApi.updateOpenCode({ replaceLegacy: options.replaceLegacy })
if (epoch !== generation) return
// Installation can leave an older shared daemon running. Re-read first so
// restart remains a separate explicit action, never an implicit interruption.
@ -160,8 +164,8 @@ export async function runOpenCodeSetup(action: OpenCodeSetupAction, options: { r
await refreshOpenCodeSetup(true)
if (epoch !== generation) return
setOpenCodeSetupError(true)
if (error instanceof Error && (error.message === "installation_busy" || error.message === "installation_in_use")) {
setOpenCodeInstallationError(error.message)
if (error instanceof Error && (INSTALLATION_ERRORS as readonly string[]).includes(error.message)) {
setOpenCodeInstallationError(error.message as OpenCodeInstallationError)
}
}
} finally {

View file

@ -43,6 +43,20 @@
display: none;
}
.opencode-installation-notice {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding-left: 0.75rem;
border-left: 2px solid var(--border-base);
}
.opencode-installation-notice,
.opencode-installation-text {
overflow-wrap: anywhere;
}
.opencode-setup-details {
border-top: 1px solid var(--border-base);
padding-top: 0.75rem;

View file

@ -901,6 +901,143 @@ test("settings distinguish manual updates and failed registry checks from an up-
} finally { await page.close() }
})
test("OpenCode 1.x npm replacement is confirmed explicitly and a failure after removal stays retryable", async () => {
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
const posts: unknown[] = []
let phase: "legacy" | "removed" | "installed" = "legacy"
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON())
if (phase === "legacy") { phase = "removed"; return route.fulfill({ status: 502, json: { success: false, error: "legacy_removed_install_failed" } }) }
phase = "installed"
return route.fulfill({ json: { success: true, version: "2.0.30" } })
}
return route.fulfill({ json: {
state: phase === "installed" ? "ready" : phase === "legacy" ? "update_required" : "missing",
currentVersion: phase === "installed" ? "2.0.30" : phase === "legacy" ? "1.18.10" : null, latestVersion: "2.0.30",
updateAvailable: phase !== "installed", canUpgrade: phase !== "installed", minimumVersion: "2.0.27", recommendedVersion: "2.0.29",
binaryPath: phase === "removed" ? "opencode2" : "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", installationSource: phase === "removed" ? undefined : "path",
...(phase === "legacy" ? { legacyReplacement: { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" } } : {}),
versionAssessment: phase === "installed" ? "untested" : "incompatible", target: "host", canRestart: false,
serviceState: phase === "installed" ? "ready" : undefined, daemonVersion: phase === "installed" ? "2.0.30" : undefined,
} })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
const replace = panel.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true })
await replace.waitFor()
await panel.getByText(/OpenCode 1\.18\.10 was installed with npm/).waitFor()
assert.equal(await panel.getByText(/Select a supported executable/).count(), 0, "a replaceable npm installation is never a manual dead end")
await replace.click()
const dialog = page.getByRole("dialog", { name: "Replace OpenCode 1.18.10?", exact: true })
await dialog.getByText(/imports OpenCode 1\.x conversations itself/).waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-legacy-confirm.png") })
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await dialog.waitFor({ state: "hidden" })
assert.deepEqual(posts, [], "dismissing the confirmation never installs")
await replace.click()
await dialog.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("alert").filter({ hasText: /OpenCode 1\.x was removed, but OpenCode 2 could not be installed/ }).waitFor()
const consent = { replaceLegacy: { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" } }
assert.deepEqual(posts, [consent], "the confirmation names the exact installation shown")
const retry = panel.getByRole("button", { name: "Install and start OpenCode", exact: true })
await retry.click()
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.deepEqual(posts, [consent, {}], "retry is an ordinary installation, never an automatic replay")
assert.equal(await page.getByRole("alert").count(), 0)
} finally { await page.close() }
})
test("an older winget OpenCode stays untouched while OpenCode 2 is installed beside it", async () => {
const page = await browser.newPage()
const posts: unknown[] = []
const statusReads: string[] = []
let installed = false
const foreignInstallation = { path: "C:/Users/fixture/AppData/Local/Microsoft/WinGet/Links/opencode.exe", version: "1.19.0", source: "winget", packageId: "SST.opencode" }
await page.route("**/api/**", route => {
const request = route.request()
if (request.method() === "GET" && new URL(request.url()).pathname === "/api/opencode/update") statusReads.push(new URL(request.url()).search)
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON()); installed = true
return route.fulfill({ json: { success: true, version: "2.0.30" } })
}
return route.fulfill({ json: { state: installed ? "ready" : "update_required", currentVersion: installed ? "2.0.30" : "1.19.0",
latestVersion: "2.0.30", updateAvailable: !installed, canUpgrade: !installed, minimumVersion: "2.0.27", recommendedVersion: "2.0.29",
binaryPath: installed ? "C:/Users/fixture/AppData/Roaming/npm/node_modules/@opencode/cli/bin/opencode.exe" : foreignInstallation.path,
installationSource: installed ? "managed" : "path", foreignInstallation, versionAssessment: installed ? "untested" : "incompatible",
target: "host", canRestart: false, serviceState: installed ? "ready" : undefined, daemonVersion: installed ? "2.0.30" : undefined } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
const install = panel.getByRole("button", { name: "Install OpenCode 2 for CodeNomad", exact: true })
await install.waitFor()
const notice = panel.getByRole("note")
await notice.getByText(/OpenCode 1\.19\.0 was found at .*WinGet.*requires OpenCode 2\.0\.27 or later and does not modify this installation/).waitFor()
await notice.getByText("This older version was installed with winget.", { exact: true }).waitFor()
await notice.getByText(/alongside the older version/).waitFor()
await notice.getByText(/may still start the older version/).waitFor()
assert.equal(await notice.locator("code").innerText(), "winget uninstall SST.opencode")
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-winget-alongside.png"), fullPage: true })
assert.ok(statusReads.length > 0 && statusReads.every(search => search === ""), "automatic reads use cached server probes")
await panel.getByRole("button", { name: "Check status and updates", exact: true }).click()
await panel.getByText("Status and update check complete.", { exact: true }).waitFor()
assert.equal(statusReads.at(-1), "?recheck=1", "the explicit Check re-probes installations")
const automaticReads = statusReads.length
await install.click()
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.equal(await page.getByRole("dialog").count(), 0, "installing beside a foreign OpenCode needs no removal confirmation")
assert.deepEqual(posts, [{}])
assert.ok(statusReads.slice(automaticReads).every(search => search === ""), "post-install reads never force a probe")
await panel.getByText(/uses its OpenCode 2 installation in your user npm folder/).waitFor()
assert.equal(await notice.getByText(/alongside the older version/).count(), 0)
await notice.getByText(/may still start the older version/).waitFor()
assert.equal(await install.count(), 0)
} finally { await page.close() }
})
test("a broken OpenCode 1.x npm install stays replaceable and a partial removal is reported truthfully", async () => {
const page = await browser.newPage()
const posts: unknown[] = []
const legacyReplacement = { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" }
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON())
return route.fulfill({ status: 502, json: { success: false, error: "legacy_partially_removed" } })
}
// The 1.x placeholder executable fails `--version`, so the probe reports an error state.
return route.fulfill({ json: { state: "error", currentVersion: null, latestVersion: "2.0.30", updateAvailable: true, canUpgrade: true,
minimumVersion: "2.0.27", recommendedVersion: "2.0.29", binaryPath: legacyReplacement.path, installationSource: "path", legacyReplacement,
versionAssessment: "untested", target: "host", canRestart: false } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("dialog", { name: "Replace OpenCode 1.18.10?", exact: true })
.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("alert").filter({ hasText: /may be only partly removed.*npm uninstall -g opencode-ai/ }).waitFor()
assert.deepEqual(posts, [{ replaceLegacy: legacyReplacement }])
} finally { await page.close() }
})
test("an outdated foreign OpenCode 2 is described by the minimum version, not as OpenCode 1.x", async () => {
const page = await browser.newPage()
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.20", latestVersion: "2.0.30",
updateAvailable: true, canUpgrade: true, minimumVersion: "2.0.27", recommendedVersion: "2.0.29", binaryPath: "/opt/homebrew/bin/opencode",
installationSource: "path", foreignInstallation: { path: "/opt/homebrew/bin/opencode", version: "2.0.20", source: "other" },
versionAssessment: "incompatible", target: "host", canRestart: false } }))
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const notice = page.locator(".opencode-setup-panel").getByRole("note")
await notice.getByText("OpenCode 2.0.20 was found at /opt/homebrew/bin/opencode. CodeNomad requires OpenCode 2.0.27 or later and does not modify this installation.", { exact: true }).waitFor()
assert.equal(await notice.getByText(/winget/).count(), 0)
} finally { await page.close() }
})
test("user npm PATH repair is available at the current version and installation conflicts retain explicit retry", async () => {
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
let migrated = false, attempts = 0, connects = 0

View file

@ -0,0 +1,69 @@
// 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))
}