From c168d764e366cc8e61900ba40986d0ee2afab96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 23 Sep 2026 13:44:59 +0200 Subject: [PATCH 1/5] fix(opencode): share the user installation with terminal clients Prefer the PATH executable over legacy private CodeNomad copies and expose the actual installation source. Use bundled Node/npm to install a standard user-global package and register its terminal command directory. Keep explicit custom and WSL selections user-managed, preserve private copies for migration, and allow migration or PATH repair even without a newer release. Serialize CodeNomad installers through a per-prefix filesystem lock, recheck versions under the lock, verify the published launcher, and avoid downgrading newer installations. Defer Windows updates before npm touches a mapped or non-writable executable; never stop the shared daemon to install. Report localized installation conflicts and retain explicit service activation. Present recovery diagnosis and installation before executable selection, while preferences retain executable-first inline controls and logs last. Keep effective version metadata synchronized and translate provenance, migration, and conflict feedback in every locale. Validated with 31 server regressions, 13 real-component browser scenarios, UI/server typechecks, and an isolated native Windows npm install covering terminal discovery, missing-launcher repair, and a live executable lock. Document migration, PATH registration, installer locking, cleanup, and platform limitations. --- AGENTS.md | 2 +- dev-docs/OPENCODE_V2_POST_BETA.md | 61 +++++-- packages/server/src/api-types.ts | 2 + .../src/opencode-update/installation-lock.ts | 38 +++++ .../src/opencode-update/service.test.ts | 22 +++ .../server/src/opencode-update/service.ts | 28 ++-- .../shared-installation.test.ts | 154 ++++++++++++++++++ .../opencode-update/shared-installation.ts | 125 ++++++++++++++ .../server/src/opencode-update/user-path.ts | 79 +++++++++ .../src/server/routes/opencode-update.test.ts | 16 ++ .../src/server/routes/opencode-update.ts | 2 + packages/server/src/server/routes/settings.ts | 3 +- packages/server/src/settings/binaries.test.ts | 4 +- packages/server/src/settings/binaries.ts | 9 +- .../components/opencode-binary-selector.tsx | 3 +- packages/ui/src/components/opencode-setup.tsx | 5 +- .../settings/opencode-executable-card.tsx | 3 +- .../settings/opencode-setup-panel.tsx | 19 ++- .../settings/opencode-update-card.tsx | 2 +- .../ui/src/lib/i18n/messages/de/instance.ts | 4 +- .../ui/src/lib/i18n/messages/de/settings.ts | 7 + .../ui/src/lib/i18n/messages/en/instance.ts | 4 +- .../ui/src/lib/i18n/messages/en/settings.ts | 7 + .../ui/src/lib/i18n/messages/es/instance.ts | 4 +- .../ui/src/lib/i18n/messages/es/settings.ts | 7 + .../ui/src/lib/i18n/messages/fr/instance.ts | 4 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 7 + .../ui/src/lib/i18n/messages/he/instance.ts | 4 +- .../ui/src/lib/i18n/messages/he/settings.ts | 7 + .../ui/src/lib/i18n/messages/ja/instance.ts | 4 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 7 + .../ui/src/lib/i18n/messages/ne/instance.ts | 4 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 7 + .../ui/src/lib/i18n/messages/ru/instance.ts | 4 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 7 + .../ui/src/lib/i18n/messages/tr/instance.ts | 4 +- .../ui/src/lib/i18n/messages/tr/settings.ts | 7 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 4 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 7 + packages/ui/src/stores/opencode-setup.ts | 14 +- .../ui/tests/browser/opencode-setup.test.ts | 45 +++++ scripts/test-opencode-install-native.mjs | 62 ++++++- 42 files changed, 734 insertions(+), 74 deletions(-) create mode 100644 packages/server/src/opencode-update/installation-lock.ts create mode 100644 packages/server/src/opencode-update/shared-installation.test.ts create mode 100644 packages/server/src/opencode-update/shared-installation.ts create mode 100644 packages/server/src/opencode-update/user-path.ts diff --git a/AGENTS.md b/AGENTS.md index 8716954b9..44df7c73e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,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 and a versioned user prefix; 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, prefers PATH and retains private copies only as migration fallbacks. 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. - Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 8b3e9b898..2e590b9b1 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -39,7 +39,7 @@ describe that earlier diff, not approval of its scope or the current correction. ## Setup and recovery -The global setup dialog and Preferences reuse `OpenCodeUpdateCard` and its store. +The global setup dialog and Preferences reuse `OpenCodeSetupPanel` and its store. This is an implementation reuse choice, not a user requirement that all failures have the same screen. Missing installations, known incompatibility and optional updates expose different actions. Installed/running/minimum/recommended versions @@ -49,21 +49,50 @@ Connection changes, foreground entry and unsupported proxy responses refresh recovery state. Stale responses cannot overwrite a changed executable or a completed action. Only a pending folder open may resume; prompts are not replayed. -Default host installations use bundled Node and the npm distributed in its pinned -official archive. Previously the packaging script retained only the Node binary -and discarded npm; the archive itself already contained npm. Both hosts now include npm's complete -dependency/license closure. npm installs an exact policy-compatible release into -a staging directory under `~/.local/share/codenomad/opencode`, verifies the real -executable, then publishes a versioned installation and an immutable version -receipt. The highest published version wins; old `current` markers remain readable. -The old unused global npm/pnpm/bun/yarn installer and legacy-package removal path -are deleted. If npm is unavailable beside the server runtime, automatic installation -is not advertised. No administrator rights, system Node, global npm install or application-resource -writes are required. Execution is bounded to five minutes and 1 MiB output. -Installation failures retain the previous selection. Concurrent requests coalesce; -another backend's same-version installation is accepted only after verification. -Exclusive receipt creation is idempotent on Windows; a slower old-version install -cannot downgrade the selection published by another backend. +Automatic selection now follows PATH order (`opencode2`, then `opencode` within +each directory), then the conventional user npm installation, then the old private +CodeNomad installation as a migration fallback. An explicit executable retains +priority. Both setup and binary validation use the same discovery. The UI shows +the effective path and source; “automatic” replaces the misleading “system PATH” +label. Recovery puts diagnosis and installation before executable selection; +Preferences keeps executable selection first, inline actions, collapsed version +details/troubleshooting and logs last. + +Default host installation uses bundled Node/npm to run a standard global npm +installation of `@opencode/cli`. Existing writable npm installations on PATH are +reused after verifying their manifest and launcher identity; standalone/curl, +Homebrew and custom installations remain user-managed. With no PATH installation, +the user prefix is `%APPDATA%/npm` on Windows and `~/.local` on POSIX, or an explicit +absolute `NPM_CONFIG_PREFIX`. Existing npm prefixes on PATH are retained. npm +publishes the normal terminal commands; CodeNomad verifies the executable version +and launcher before registering the command directory. Windows registration preserves +the HKCU Path value's type and unexpanded variables and broadcasts the environment +change. Bash, zsh, sh and fish profiles receive idempotent entries. A new terminal +is needed; the backend's own PATH is updated immediately. Remote installation and +PATH changes apply on the server host. No system Node or administrator rights are +needed for the conventional user prefix. Installer execution remains bounded to +five minutes and 1 MiB output. + +Migration remains available when a private copy is already current. PATH registration +failure leaves the installed package discoverable, and retry can repair PATH without +reinstalling. Neither migration nor command repair downgrades a newer shared version. +Old version directories and selection receipts are read-only fallbacks and are never +removed by installation. After choosing/verifying the common executable and checking +that no process uses the private copy, the user may remove only +`~/.local/share/codenomad/opencode`; `~/.local/share/opencode` is native user data +and must not be confused with this installation directory. + +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 +by time/PID heuristics: npm may outlive its backend. The server log gives the lock +path; remove it only after confirming the installer has exited. External package +managers do not participate in this lock. Windows checks the executable for write +access before npm can retire the old package; a mapped or non-writable executable +defers the update with localized feedback and leaves the package intact. CodeNomad +never stops the shared daemon to complete an installation. Standard npm's own +failure semantics apply after this preflight; this is not the old immutable, +versioned private-package publication scheme. Explicit custom binaries remain selected. WSL and custom installations receive execution-host instructions rather than a Windows-side Linux installation. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 255d46c46..201082107 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -388,6 +388,8 @@ export interface OpenCodeUpdateStatus { incompatibilityReason?: "step_timestamp" | "canonical_api" | "session_environment" state: "missing" | "update_required" | "ready" | "error" binaryPath: string + installationSource?: "path" | "user" | "legacy" + needsSharedInstallation?: boolean daemonVersion?: string serviceState?: "stopped" | "ready" | "restart_required" | "restart_available" | "incompatible" | "error" canReload?: boolean diff --git a/packages/server/src/opencode-update/installation-lock.ts b/packages/server/src/opencode-update/installation-lock.ts new file mode 100644 index 000000000..6aadf7703 --- /dev/null +++ b/packages/server/src/opencode-update/installation-lock.ts @@ -0,0 +1,38 @@ +import { mkdir, open, rm } from "node:fs/promises" +import path from "node:path" + +export class InstallationBusyError extends Error { + constructor(readonly code: "installation_busy" | "installation_in_use", message: string) { + super(message) + } +} + +/** Serializes CodeNomad backends sharing a standard npm prefix. Never steal a + * lock on a timeout: an orphaned npm child can outlive its backend. After a crash, + * remove the lock only after checking that the installer has exited. */ +export async function withInstallationLock(prefix: string, install: () => Promise): Promise { + await mkdir(prefix, { recursive: true }) + const lock = path.join(prefix, ".codenomad-opencode-install.lock") + const handle = await open(lock, "wx", 0o600).catch(error => { + if (error.code === "EEXIST") throw new InstallationBusyError("installation_busy", `OpenCode installation lock exists: ${lock}`) + throw error + }) + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, started: new Date().toISOString() })) + return await install() + } finally { + await handle.close() + await rm(lock) + } +} + +/** Windows forbids writing a mapped executable. Check before npm can retire the + * old package; do not stop the daemon or leave a partially replaced installation. */ +export async function assertExecutableWritable(binary: string, platform = process.platform) { + if (platform !== "win32") return + try { await (await open(binary, "r+")).close() } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw new InstallationBusyError("installation_in_use", "The OpenCode executable is in use or not writable") + } +} diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 8d16f2b1b..1a5cd6f83 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -76,6 +76,28 @@ test("installation re-resolves the executable and rejects false success", async await assert.rejects(wrong.upgrade(), (error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed") }) +test("legacy migration and PATH repair remain available without an update and never downgrade", async () => { + for (const source of ["legacy", "user"] as const) { + let migrated = false, installs = 0 + const service = new OpenCodeUpdateService(deps({ + resolveBinary: () => ({ path: migrated ? "common" : source, label: "OpenCode", source: migrated ? "path" : source }), + probeBinary: () => ({ valid: true, version: "2.0.15" }), + resolveLatestVersion: async () => "2.0.14", + upgradeBinary: async (_binary, target) => { + assert.equal(target, "2.0.15") + installs++; migrated = true + return { success: true, version: target } + }, + })) + assert.equal((await service.getStatus()).canUpgrade, true) + assert.equal((await service.getStatus()).needsSharedInstallation, true) + await service.upgrade() + assert.equal(installs, 1) + assert.equal((await service.getStatus()).needsSharedInstallation, false) + assert.equal((await service.getStatus()).canUpgrade, false) + } +}) + test("coalesces overlapping installations and never downgrades a newer version", async () => { let upgrades = 0 let version = "2.0.6" diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 54a1e9a3b..57249b4cd 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -9,7 +9,9 @@ import { assertSupportedOpenCode, isBelowOpenCodeMinimum, MINIMUM_OPENCODE_VERSI import { contractProfile, runtimeIdentity } from "../opencode/compatibility/runtime" import { createRuntimeTransport } from "../opencode/compatibility/transport" import type { Endpoint } from "@opencode/client/service" -import { bundledNpm, installManagedOpenCode } from "./managed-installation" +import { bundledNpm } from "./managed-installation" +import { installSharedOpenCode, sharedInstallPrefix } from "./shared-installation" +import { InstallationBusyError } from "./installation-lock" import type { OpenCodeServiceLifecycle } from "../workspaces/opencode-service" import { parseWslUncPath } from "../workspaces/spawn" @@ -69,9 +71,10 @@ export class OpenCodeUpdateService { ? !currentVersion || compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 : null const status: OpenCodeUpdateStatus = { currentVersion, latestVersion, updateAvailable, - canUpgrade: !invalid && Boolean(updateAvailable) && Boolean(latestVersion && !isBelowOpenCodeMinimum(latestVersion)) && this.deps.canUpgradeBinary(binary), + canUpgrade: !invalid && 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), target: parseWslUncPath(binary.path) ? "wsl" : "host", canRestart: false, ...(!latestVersion ? { checkError: "update_check_failed" as const } : {}), } @@ -201,24 +204,25 @@ export class OpenCodeUpdateService { throw new OpenCodeUpdateError("unsupported_binary", "Cannot order this custom OpenCode version for an automatic update") } - if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) { + if (currentVersion && compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0 && !needsSharedInstallation(binary)) { return { success: true, version: currentVersion } } if (!this.deps.canUpgradeBinary(binary)) { throw new OpenCodeUpdateError( "unsupported_binary", - "Automatic updates are only available for the managed opencode2 command", + "Automatic updates require the default command and a writable npm installation", ) } try { - const result = await this.deps.upgradeBinary(binary, latestVersion) + const targetVersion = currentVersion && compareOpenCodeVersionStrings(currentVersion, latestVersion) > 0 ? currentVersion : latestVersion + const result = await this.deps.upgradeBinary(binary, targetVersion) if (!result.success) { throw new OpenCodeUpdateError("upgrade_failed", result.error) } const installedVersion = await this.readCurrentVersion(this.deps.resolveBinary().path) - if (isBelowOpenCodeMinimum(installedVersion) || !/^\d+\.\d+\.\d+$/.test(installedVersion) || compareOpenCodeVersionStrings(installedVersion, latestVersion) < 0) { + if (isBelowOpenCodeMinimum(installedVersion) || !/^\d+\.\d+\.\d+$/.test(installedVersion) || compareOpenCodeVersionStrings(installedVersion, targetVersion) < 0) { throw new OpenCodeUpdateError( "upgrade_verification_failed", `OpenCode reported ${result.version}, but the configured binary is ${installedVersion} instead of ${latestVersion}`, @@ -226,7 +230,7 @@ export class OpenCodeUpdateService { } return { success: true, version: installedVersion } } catch (error) { - if (error instanceof OpenCodeUpdateError) throw error + if (error instanceof OpenCodeUpdateError || error instanceof InstallationBusyError) throw error throw new OpenCodeUpdateError( "upgrade_failed", error instanceof Error ? error.message : "OpenCode upgrade failed", @@ -300,12 +304,12 @@ export function createOpenCodeUpdateService( }, probeBinary: probeBinaryVersionAsync, resolveLatestVersion: resolveLatestOpenCodeVersion, - canUpgradeBinary: () => { + canUpgradeBinary: binary => { const configured = settings.getOwner("config", "server").opencodeBinary - return (!configured || configured === "opencode" || configured === "opencode2") && Boolean(bundledNpm()) + return !parseWslUncPath(binary.path) && (!configured || configured === "opencode" || configured === "opencode2") && Boolean(bundledNpm()) && Boolean(sharedInstallPrefix()) }, upgradeBinary: async (_binary, version) => { - await installManagedOpenCode(version) + await installSharedOpenCode(version) return { success: true, version } }, lifecycle: binary => workspaceManager.setupServiceOptions(binary.path).then(options => options.lifecycle), @@ -318,3 +322,7 @@ export function createOpenCodeUpdateService( function comparableVersion(version: string): boolean { return /^\d+\.\d+\.\d+$/.test(version) || /^0\.0\.0-beta-\d+$/.test(version) } + +function needsSharedInstallation(binary: ResolvedBinary): boolean { + return binary.source === "legacy" || binary.source === "user" +} diff --git a/packages/server/src/opencode-update/shared-installation.test.ts b/packages/server/src/opencode-update/shared-installation.test.ts new file mode 100644 index 000000000..4fa6c4321 --- /dev/null +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtemp, mkdir, writeFile, readFile, rm, symlink } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { findPathOpenCode, installSharedOpenCode, npmCommandDirectory, npmExecutable, resolveDefaultInstallation, sharedInstallPrefix, userNpmPrefix } from "./shared-installation" +import { registerUserPath } from "./user-path" +import { withInstallationLock } from "./installation-lock" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +async function npmFixture(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" })) + const bin = npmCommandDirectory(prefix) + await mkdir(bin, { recursive: true }) + const command = path.join(bin, process.platform === "win32" ? "opencode2.cmd" : "opencode2") + if (process.platform === "win32") await writeFile(command, '@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n') + else await symlink(binary, command).catch(error => { if (error.code !== "EEXIST") throw error }) + return command +} + +test("PATH wins over newer private copies; existing user npm and private installations remain migration fallbacks", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-")) + const env = { PATH: "", APPDATA: path.join(home, "AppData") } + const host = { home, env } + try { + const root = path.join(home, ".local/share/codenomad/opencode") + const legacy = path.join(root, "2.0.99/node_modules/@opencode/cli/bin/opencode.exe") + await mkdir(path.dirname(legacy), { recursive: true }); await writeFile(legacy, "legacy") + await mkdir(path.join(root, "selected")); await writeFile(path.join(root, "selected/2.0.99"), "") + assert.deepEqual(resolveDefaultInstallation(host), { path: legacy, source: "legacy" }) + const prefix = userNpmPrefix(host) + await npmFixture(prefix, "2.0.11") + assert.deepEqual(resolveDefaultInstallation(host), { path: npmExecutable(prefix), source: "user" }) + const existing = path.join(home, "existing npm") + const command = await npmFixture(existing, "2.0.12") + env.PATH = npmCommandDirectory(existing) + assert.deepEqual(resolveDefaultInstallation(host), { path: command, source: "path" }) + assert.equal(sharedInstallPrefix(host), existing) + assert.equal(await readFile(legacy, "utf8"), "legacy", "discovery never removes private installations") + } finally { await rm(home, { recursive: true, force: true }) } +}) + +test("normal global-user npm install publishes terminal command and retries PATH registration without reinstalling", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-install-")) + const env = { PATH: "", APPDATA: path.join(home, "AppData"), SHELL: "/bin/bash" } + let installs = 0, registrations = 0 + const prefix = userNpmPrefix({ home, env }) + const options = { home, env, node: process.execPath, npm: "fixture-npm.js", + execute: async (_file: string, args: string[]) => { + installs++ + assert.ok(args.includes("--global")) + assert.equal(args[args.indexOf("--prefix") + 1], prefix) + await npmFixture(prefix, args.at(-1)!.split("@").at(-1)!) + }, + probe: async (binary: string) => { + try { return { valid: true, version: await readFile(binary, "utf8") } } catch { return { valid: false, missing: true } } + }, + registerPath: async (directory: string) => { + if (++registrations === 1) throw new Error("PATH write failed") + await registerUserPath(directory, { home, env, registerWindowsPath: async () => {} }) + }, + } + try { + await assert.rejects(installSharedOpenCode("2.0.11", options), /PATH write failed/) + assert.equal((await options.probe(npmExecutable(prefix))).version, "2.0.11", "installation survives PATH failure") + assert.equal((resolveDefaultInstallation(options)).source, "user", "repair remains discoverable") + assert.equal(await installSharedOpenCode("2.0.11", options), npmExecutable(prefix)) + assert.equal(installs, 1) + assert.equal(resolveDefaultInstallation(options).path, findPathOpenCode(options)) + assert.equal(resolveDefaultInstallation(options).source, "path") + await installSharedOpenCode("2.0.12", options) + assert.equal(installs, 2) + await installSharedOpenCode("2.0.11", options) + assert.equal(installs, 2, "never downgrade a newer common installation") + assert.equal((await options.probe(npmExecutable(prefix))).version, "2.0.12") + await rm(findPathOpenCode(options)!) + await installSharedOpenCode("2.0.11", options) + assert.equal(installs, 3, "missing command is repaired at the newer installed version") + assert.equal((await options.probe(npmExecutable(prefix))).version, "2.0.12") + } finally { await rm(home, { recursive: true, force: true }) } +}) + +test("independent backends cannot replace a shared installation concurrently; failures release the lock", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-lock-")) + try { + await withInstallationLock(home, async () => { + const module = new URL("./installation-lock.ts", import.meta.url).href + const script = `import { withInstallationLock } from ${JSON.stringify(module)}; + try { await withInstallationLock(${JSON.stringify(home)}, async () => { throw new Error('entered'); }); process.exit(2); } + catch (error) { if (error.code !== 'installation_busy') throw error; }` + await promisify(execFile)(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script]) + }) + await assert.rejects(withInstallationLock(home, async () => { throw new Error("fixture failure") }), /fixture failure/) + assert.equal(await withInstallationLock(home, async () => "released"), "released") + } finally { await rm(home, { recursive: true, force: true }) } +}) + +test("standalone opencode commands and custom npm prefixes are discovered without mutation", async () => { + 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 } } + assert.equal(userNpmPrefix(host), prefix) + await npmFixture(prefix, "2.0.12") + assert.equal(resolveDefaultInstallation(host).path, npmExecutable(prefix)) + const command = path.join(home, process.platform === "win32" ? "opencode.exe" : "opencode") + await writeFile(command, "standalone", { mode: 0o755 }) + host.env.PATH = home + assert.equal(resolveDefaultInstallation(host).path, command) + assert.equal(sharedInstallPrefix(host), undefined) + } finally { await rm(home, { recursive: true, force: true }) } +}) + +test("non-npm PATH executables are reused without allowing npm to shadow them", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-custom-")) + 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" } + assert.equal(resolveDefaultInstallation(options).path, binary) + assert.equal(sharedInstallPrefix(options), undefined) + await assert.rejects(installSharedOpenCode("2.0.11", options), /not a writable npm installation/) + } finally { await rm(home, { recursive: true, force: true }) } +}) + +test("shell PATH registration preserves profiles and is idempotent for bash, zsh, fish and Windows", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "opencode-shells-")) + try { + for (const shell of ["bash", "zsh", "fish"]) { + const user = path.join(home, shell) + const bin = `/users/example/${shell}/bin with spaces` + await mkdir(user) + const env = { PATH: "/existing", SHELL: `/bin/${shell}` } + await writeFile(path.join(user, ".profile"), "# user settings\n") + const options = { home: user, env, platform: "linux" as const } + await registerUserPath(bin, options) + const file = shell === "bash" ? ".bashrc" : shell === "zsh" ? ".zshrc" : ".config/fish/conf.d/opencode-path.fish" + const first = await readFile(path.join(user, file), "utf8") + await registerUserPath(bin, options) + assert.equal(await readFile(path.join(user, file), "utf8"), first) + assert.ok((await readFile(path.join(user, ".profile"), "utf8")).startsWith("# user settings\n")) + assert.equal(env.PATH, `/existing:${bin}`) + } + const env = { Path: "C:\\existing" } + let registered = "" + await registerUserPath("C:\\user\\npm", { home, platform: "win32", env, registerWindowsPath: async bin => { registered = bin } }) + assert.equal(registered, "C:\\user\\npm") + assert.equal(env.Path, "C:\\existing;C:\\user\\npm") + } finally { await rm(home, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts new file mode 100644 index 000000000..bf10a5023 --- /dev/null +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -0,0 +1,125 @@ +import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { bundledNpm, executeInstaller, readManagedExecutable } from "./managed-installation" +import { registerUserPath } from "./user-path" +import { probeBinaryVersionAsync, buildSpawnSpec } from "../workspaces/spawn" +import { assertSupportedOpenCode } from "../opencode/runtime-support" +import { compareVersionStrings } from "../releases/release-monitor" +import { assertExecutableWritable, withInstallationLock } from "./installation-lock" + +export interface InstallationHost { + home?: string + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv +} + +export function userNpmPrefix(host: InstallationHost = {}): string { + const home = host.home ?? os.homedir() + const env = host.env ?? process.env + const configured = Object.entries(env).find(([key]) => key.toLowerCase() === "npm_config_prefix")?.[1] + if (configured && path.isAbsolute(configured)) return configured + return (host.platform ?? process.platform) === "win32" + ? path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "npm") : path.join(home, ".local") +} + +export function npmCommandDirectory(prefix: string, platform = process.platform): string { + return platform === "win32" ? prefix : path.join(prefix, "bin") +} + +export function npmExecutable(prefix: string, platform = process.platform): string { + return path.join(prefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", "@opencode", "cli", "bin", "opencode.exe") +} + +export function findPathOpenCode(host: InstallationHost = {}): string | undefined { + const env = host.env ?? process.env + const platform = host.platform ?? process.platform + const key = Object.keys(env).find(key => key.toLowerCase() === "path") + const extensions = platform === "win32" ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").toLowerCase().split(";") : [""] + for (const entry of (env[key ?? "PATH"] || "").split(platform === "win32" ? ";" : ":")) { + const directory = entry.replace(/^"|"$/g, "") + if (!directory || !path.isAbsolute(directory)) continue + for (const name of ["opencode2", "opencode"]) for (const extension of extensions) { + const candidate = path.join(directory, `${name}${extension}`) + try { + if (!statSync(candidate).isFile()) continue + accessSync(candidate, platform === "win32" ? constants.F_OK : constants.X_OK) + return candidate + } catch { /* Continue in PATH order. */ } + } + } +} + +export function resolveDefaultInstallation(host: InstallationHost = {}): { path: string; source?: "path" | "user" | "legacy" } { + const command = findPathOpenCode(host) + if (command) return { path: command, source: "path" } + const binary = npmExecutable(userNpmPrefix(host), host.platform) + if (existsSync(binary)) return { path: binary, source: "user" } + // Migration fallback only: never override an installation shared through PATH. + const legacy = readManagedExecutable(path.join(host.home ?? os.homedir(), ".local", "share", "codenomad", "opencode")) + return legacy ? { path: legacy, source: "legacy" } : { 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 { + const platform = host.platform ?? process.platform + const userPrefix = userNpmPrefix(host) + 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)) + const binary = npmExecutable(prefix, platform) + try { + const manifest = JSON.parse(readFileSync(path.join(path.dirname(binary), "..", "package.json"), "utf8")) + if (manifest.name !== "@opencode/cli") return undefined + const resolved = platform === "win32" ? buildSpawnSpec(command, [], { platform, env: host.env }).command : realpathSync(command) + if (realpathSync(resolved) !== realpathSync(binary)) return undefined + accessSync(prefix, constants.W_OK) + return prefix + } catch { return undefined } +} + +export async function installSharedOpenCode(version: string, options: InstallationHost & { + node?: string; npm?: string + execute?: typeof executeInstaller + probe?: typeof probeBinaryVersionAsync + registerPath?: (directory: string) => Promise +} = {}): Promise { + 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 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 binary = npmExecutable(prefix, platform) + const probe = options.probe ?? probeBinaryVersionAsync + return withInstallationLock(prefix, async () => { + const existing = await probe(binary) + if (existing.valid && (!existing.version || !/^\d+\.\d+\.\d+$/.test(existing.version))) { + throw new Error("Cannot replace an unverified OpenCode version automatically") + } + const target = existing.valid && existing.version && compareVersionStrings(existing.version, version) > 0 ? existing.version : version + const directory = npmCommandDirectory(prefix, platform) + const commandHost = { ...options, env: { PATH: directory, PATHEXT: ".EXE;.CMD;.BAT" } } + if (!existing.valid || existing.version !== target || !findPathOpenCode(commandHost)) { + await assertExecutableWritable(binary, platform) + 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] || ""}` + await (options.execute ?? executeInstaller)(node, [npm, "install", "--global", "--prefix", prefix, + "--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", `@opencode/cli@${target}`], env) + } + const result = await probe(binary) + if (!result.valid || !result.version || !/^\d+\.\d+\.\d+$/.test(result.version) || compareVersionStrings(result.version, target) < 0) { + throw new Error("Installed OpenCode version verification failed") + } + if (!findPathOpenCode(commandHost) || sharedInstallPrefix(commandHost) !== prefix) { + throw new Error("npm did not publish the verified OpenCode terminal command") + } + await (options.registerPath ?? (directory => registerUserPath(directory, options)))(directory) + return binary + }) +} diff --git a/packages/server/src/opencode-update/user-path.ts b/packages/server/src/opencode-update/user-path.ts new file mode 100644 index 000000000..800aa75f8 --- /dev/null +++ b/packages/server/src/opencode-update/user-path.ts @@ -0,0 +1,79 @@ +import { execFile } from "node:child_process" +import { mkdir, readFile, appendFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export interface UserPathOptions { + home?: string + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + registerWindowsPath?: (bin: string) => Promise +} + +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'` + +async function addProfileLine(file: string, line: string) { + let existing = "" + try { existing = await readFile(file, "utf8") } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error } + if (existing.split(/\r?\n/).includes(line)) return + await mkdir(path.dirname(file), { recursive: true }) + await appendFile(file, `\n# OpenCode user installation PATH (CodeNomad)\n${line}\n`, { mode: 0o600 }) +} + +export async function registerWindowsUserPath(bin: string): Promise { + // Preserve unexpanded %VARIABLE% entries and their registry value type. + const script = `$ErrorActionPreference = 'Stop' +$bin = '${bin.replaceAll("'", "''")}' +$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') +try { + $raw = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $kind = if ($key.GetValueNames() -contains 'Path') { $key.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString } + $found = @($raw -split ';' | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\') -ieq $bin.TrimEnd('\\') }).Count -gt 0 + if (-not $found) { $key.SetValue('Path', (($raw.TrimEnd(';') + ';' + $bin).TrimStart(';')), $kind) } +} finally { $key.Dispose() } +Add-Type -Namespace CodeNomad -Name EnvironmentBroadcast -MemberDefinition '[System.Runtime.InteropServices.DllImport("user32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr h, uint m, System.UIntPtr w, string l, uint f, uint t, out System.UIntPtr r);' +$result = [UIntPtr]::Zero +[void][CodeNomad.EnvironmentBroadcast]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 2000, [ref]$result) +` + const executable = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + await new Promise((resolve, reject) => execFile(executable, + ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], + { windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024 }, error => error ? reject(new Error("Could not register the user terminal PATH")) : resolve())) +} + +/** Called only by an explicit installation, never by discovery/status reads. */ +export async function registerUserPath(bin: string, options: UserPathOptions = {}): Promise { + const home = options.home ?? os.homedir() + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const shell = path.basename(env.SHELL || (platform === "darwin" ? "/bin/zsh" : "/bin/bash")) + if (platform === "win32") { + await (options.registerWindowsPath ?? registerWindowsUserPath)(bin) + } else { + const line = `case ":$PATH:" in *:${quote(bin)}:*) ;; *) export PATH="$PATH":${quote(bin)} ;; esac` + if (shell === "fish") { + const config = env.XDG_CONFIG_HOME || path.join(home, ".config") + // Fish single-quoted strings only interpret escaped quote/backslash. + const fishBin = `'${bin.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + await addProfileLine(path.join(config, "fish", "conf.d", "opencode-path.fish"), + `if not contains -- ${fishBin} $PATH; set -gx PATH $PATH ${fishBin}; end`) + } else if (shell === "zsh") { + for (const name of [".zprofile", ".zshrc"]) await addProfileLine(path.join(env.ZDOTDIR || home, name), line) + } else if (shell === "bash" || shell === "sh") { + await addProfileLine(path.join(home, ".profile"), line) + if (shell === "bash") { + await addProfileLine(path.join(home, ".bashrc"), line) + for (const name of [".bash_profile", ".bash_login"]) { + try { await readFile(path.join(home, name)); await addProfileLine(path.join(home, name), line) } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error } + } + } + } else throw new Error("Register the OpenCode installation directory in this shell's PATH manually") + } + const key = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH" + const delimiter = platform === "win32" ? ";" : ":" + const normalize = (value: string) => platform === "win32" ? value.toLowerCase().replace(/[\\/]+$/, "") : value + if (!(env[key] || "").split(delimiter).some(value => normalize(value) === normalize(bin))) { + env[key] = [env[key], bin].filter(Boolean).join(delimiter) + } +} diff --git a/packages/server/src/server/routes/opencode-update.test.ts b/packages/server/src/server/routes/opencode-update.test.ts index 595044d95..0687ae906 100644 --- a/packages/server/src/server/routes/opencode-update.test.ts +++ b/packages/server/src/server/routes/opencode-update.test.ts @@ -4,6 +4,7 @@ import Fastify from "fastify" import type { Logger } from "../../logger" import type { OpenCodeUpdateService } from "../../opencode-update/service" import { registerOpenCodeUpdateRoutes } from "./opencode-update" +import { InstallationBusyError } from "../../opencode-update/installation-lock" test("does not pass request-controlled binary paths to the update service", async () => { const calls: Array<{ method: string; args: unknown[] }> = [] @@ -58,3 +59,18 @@ test("service activation accepts only exclusive explicit restart/reload intents" assert.deepEqual(calls, [false, true, "reload"]) } finally { await app.close() } }) + +test("installation conflicts expose localized error codes without leaking host paths", async () => { + for (const code of ["installation_busy", "installation_in_use"] as const) { + const app = Fastify() + registerOpenCodeUpdateRoutes(app, { + service: { upgrade: async () => { throw new InstallationBusyError(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, 409) + assert.deepEqual(response.json(), { success: false, error: code }) + } finally { await app.close() } + } +}) diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts index 043db60eb..06055f27f 100644 --- a/packages/server/src/server/routes/opencode-update.ts +++ b/packages/server/src/server/routes/opencode-update.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify" import type { Logger } from "../../logger" import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service" import { z } from "zod" +import { InstallationBusyError } from "../../opencode-update/installation-lock" interface RouteDeps { service: OpenCodeUpdateService @@ -15,6 +16,7 @@ function statusCode(error: OpenCodeUpdateError): number { } function requestError(error: unknown, fallback: string): { status: number; code: string } { + if (error instanceof InstallationBusyError) return { status: 409, code: error.code } if (error instanceof OpenCodeUpdateError) return { status: statusCode(error), code: error.code } return { status: 500, code: fallback } } diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 766d03e16..21aa7bc35 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -5,6 +5,7 @@ import { probeOpenCodeBinary } from "../../workspaces/spawn" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config" +import { resolveDefaultInstallation } from "../../opencode-update/shared-installation" interface RouteDeps { settings: SettingsService @@ -120,7 +121,7 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/storage/binaries/validate", async (request, reply) => { try { const body = ValidateBinarySchema.parse(request.body ?? {}) - return validateBinaryPath(body.path) + return validateBinaryPath(body.path === "opencode2" || body.path === "opencode" ? resolveDefaultInstallation().path : body.path) } catch (error) { deps.logger.warn({ err: error }, "Failed to validate binary") reply.code(400) diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts index f8260d5c6..092102d51 100644 --- a/packages/server/src/settings/binaries.test.ts +++ b/packages/server/src/settings/binaries.test.ts @@ -23,7 +23,7 @@ describe("BinaryResolver", () => { ? { opencodeBinaries: [{ path: "listed-but-not-global" }] } : {}, } as unknown as SettingsService - assert.equal(new BinaryResolver(settings).resolveDefault().path, "opencode2") + assert.equal(new BinaryResolver(settings, () => ({ path: "opencode2", source: "path" })).resolveDefault().path, "opencode2") }) it("upgrades the legacy bare opencode default to opencode2", () => { @@ -34,6 +34,6 @@ describe("BinaryResolver", () => { }, } as unknown as SettingsService - assert.equal(new BinaryResolver(settings).resolveDefault().path, "opencode2") + assert.equal(new BinaryResolver(settings, () => ({ path: "opencode2", source: "path" })).resolveDefault().path, "opencode2") }) }) diff --git a/packages/server/src/settings/binaries.ts b/packages/server/src/settings/binaries.ts index e4d75807d..8ac482cfc 100644 --- a/packages/server/src/settings/binaries.ts +++ b/packages/server/src/settings/binaries.ts @@ -1,5 +1,5 @@ import type { SettingsService } from "./service" -import { readManagedExecutable } from "../opencode-update/managed-installation" +import { resolveDefaultInstallation } from "../opencode-update/shared-installation" export interface OpenCodeBinaryEntry { path: string @@ -12,6 +12,7 @@ export interface ResolvedBinary { path: string label: string version?: string + source?: "path" | "user" | "legacy" } function prettyLabel(p: string): string { @@ -34,7 +35,7 @@ function readDefaultBinaryPath(settings: SettingsService): string | undefined { } export class BinaryResolver { - constructor(private readonly settings: SettingsService) {} + constructor(private readonly settings: SettingsService, private readonly resolveInstallation = resolveDefaultInstallation) {} list(): OpenCodeBinaryEntry[] { return readUiBinaries(this.settings) @@ -44,13 +45,15 @@ export class BinaryResolver { const binaries = this.list() const configuredDefault = readDefaultBinaryPath(this.settings) const selected = !configuredDefault || configuredDefault === "opencode" ? "opencode2" : configuredDefault - const path = selected === "opencode2" ? readManagedExecutable() ?? selected : selected + const installation = selected === "opencode2" ? this.resolveInstallation() : undefined + const path = installation?.path ?? selected const entry = binaries.find((b) => b.path === path) return { path, label: entry?.label ?? prettyLabel(path), version: entry?.version, + ...(installation ? { source: installation.source } : {}), } } } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index e1fc1d676..62e7272af 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -22,6 +22,7 @@ interface OpenCodeBinarySelectorProps { onBinaryChange: (binary: string) => void disabled?: boolean isVisible?: boolean + defaultVersion?: string } const OpenCodeBinarySelector: Component = (props) => { @@ -281,7 +282,7 @@ const OpenCodeBinarySelector: Component = (props) = {(binary) => { const isDefault = binary.isDefault - const versionLabel = () => versionInfo().get(binary.path) ?? binary.version + const versionLabel = () => (isDefault ? props.defaultVersion : undefined) ?? versionInfo().get(binary.path) ?? binary.version return (
-
{t("settings.opencode.update.title")} +
{t("settings.opencode.setup.required")} {t("app.launchError.close")}
-
+
diff --git a/packages/ui/src/components/settings/opencode-executable-card.tsx b/packages/ui/src/components/settings/opencode-executable-card.tsx index c7283b557..f24067516 100644 --- a/packages/ui/src/components/settings/opencode-executable-card.tsx +++ b/packages/ui/src/components/settings/opencode-executable-card.tsx @@ -1,7 +1,7 @@ import { Terminal } from "lucide-solid" import { useI18n } from "../../lib/i18n" import { useConfig } from "../../stores/preferences" -import { openCodeSetupBusy } from "../../stores/opencode-setup" +import { openCodeSetupBusy, openCodeSetupStatus } from "../../stores/opencode-setup" import OpenCodeBinarySelector from "../opencode-binary-selector" export function OpenCodeExecutableCard() { @@ -19,6 +19,7 @@ export function OpenCodeExecutableCard() { {t("settings.scope.server")}
} diff --git a/packages/ui/src/components/settings/opencode-setup-panel.tsx b/packages/ui/src/components/settings/opencode-setup-panel.tsx index c86a6ac63..de8cbcc6e 100644 --- a/packages/ui/src/components/settings/opencode-setup-panel.tsx +++ b/packages/ui/src/components/settings/opencode-setup-panel.tsx @@ -1,16 +1,14 @@ -import { Show } from "solid-js" +import { Show, type ParentProps } from "solid-js" import { useI18n } from "../../lib/i18n" -import { OpenCodeExecutableCard } from "./opencode-executable-card" import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError, - openCodeSetupChecking as checking, openCodeSetupAction as action, openCodeSetupFeedback, + openCodeSetupChecking as checking, openCodeSetupAction as action, openCodeSetupFeedback, openCodeInstallationError, isOpenCodeConnected, canContinueOpenCodeSetup, continueOpenCodeSetup, refreshOpenCodeSetup, runOpenCodeSetup } from "../../stores/opencode-setup" -export function OpenCodeSetupPanel(props: { showExecutable?: boolean } = {}) { +export function OpenCodeSetupPanel(props: ParentProps = {}) { const { t } = useI18n() const disabled = () => busy() || checking() return
- {t(openCodeSetupError() ? "settings.opencode.update.checkFailed" : "settings.opencode.update.checking")}

}> {data => <>

{t(isOpenCodeConnected() ? "settings.opencode.setup.connected" @@ -20,6 +18,9 @@ export function OpenCodeSetupPanel(props: { showExecutable?: boolean } = {}) {

{t("settings.opencode.setup.daemon")}{data().daemonVersion ?? "—"}

{data().binaryPath}

+ + {source =>

{t(`settings.opencode.setup.source.${source()}`)}

} +

{t("settings.opencode.update.available", { version: data().latestVersion ?? "" })}

@@ -40,7 +41,7 @@ export function OpenCodeSetupPanel(props: { showExecutable?: boolean } = {}) {
@@ -50,14 +51,18 @@ export function OpenCodeSetupPanel(props: { showExecutable?: boolean } = {}) {
+ +

{t("settings.opencode.setup.sharedInstallDescription")}

+
} - +

{t(action() === "install" ? "settings.opencode.update.updating" : `settings.opencode.setup.progress.${action()}`)}

{t(`settings.opencode.setup.${openCodeSetupFeedback()}`)}

+ {props.children} {data => <>
diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index f32d27d5d..eb49159be 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -12,7 +12,7 @@ export const OpenCodeUpdateCard: Component = () => {

{t("settings.opencode.setup.description")}

{t("settings.scope.server")}
- +
} diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index f08c48347..2c6361998 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -264,7 +264,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": "Binärdatei aus dem System-PATH verwenden", + "opencodeBinarySelector.badge.systemPath": "Zuerst PATH, dann Benutzerinstallation; alte CodeNomad-Kopie als Rückfall", "opencodeBinarySelector.status.checkingVersions": "Versionen werden geprüft...", "opencodeBinarySelector.status.checking": "Wird geprüft...", "opencodeBinarySelector.dialog.title": "OpenCode-Binärdatei auswählen", @@ -272,6 +272,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Ungültige OpenCode-Binärdatei", "opencodeBinarySelector.validation.v2Required": "CodeNomad benötigt OpenCode V2. Installieren und wählen Sie die opencode2-Binärdatei aus.", "opencodeBinarySelector.validation.alreadyValidating": "Wird bereits validiert", - "opencodeBinarySelector.display.systemPath": "{name} (System-PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (automatisch)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index adb7db474..aa64bc3f7 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Eine andere Installation hält die OpenCode-Installationssperre. Nach deren Abschluss erneut versuchen. Bei einem Abbruch steht der Sperrpfad im Serverprotokoll.", + "settings.opencode.setup.installation_in_use": "Die OpenCode-Datei wird verwendet oder ist nicht beschreibbar. Das Update wurde vor Änderungen zurückgestellt. Erneut versuchen, sobald die Datei verfügbar ist.", + "settings.opencode.setup.source.path": "Programm im PATH des Servers gefunden.", + "settings.opencode.setup.source.user": "npm-Benutzerinstallation außerhalb des aktuellen PATH gefunden.", + "settings.opencode.setup.source.legacy": "Alte private CodeNomad-Installation. Keine gemeinsame Installation 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. Alte private Kopien bleiben erhalten.", "settings.opencode.update.available": "OpenCode {version} ist verfügbar.", "settings.opencode.setup.manage": "OpenCode verwalten…", "settings.opencode.setup.connected": "OpenCode ist verbunden.", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index cfba995e5..649523410 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -264,7 +264,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "Add", "opencodeBinarySelector.actions.browse": "Browse for Binary…", "opencodeBinarySelector.actions.removeTitle": "Remove binary", - "opencodeBinarySelector.badge.systemPath": "Use binary from system PATH", + "opencodeBinarySelector.badge.systemPath": "Prefer PATH, then the user installation; legacy CodeNomad copy as fallback", "opencodeBinarySelector.status.checkingVersions": "Checking versions…", "opencodeBinarySelector.status.checking": "Checking…", "opencodeBinarySelector.dialog.title": "Select OpenCode Binary", @@ -272,6 +272,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Invalid OpenCode binary", "opencodeBinarySelector.validation.v2Required": "CodeNomad requires OpenCode V2. Install and select the opencode2 binary.", "opencodeBinarySelector.validation.alreadyValidating": "Already validating", - "opencodeBinarySelector.display.systemPath": "{name} (system PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (automatic)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index da5bbf2f8..18f93dd94 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Another installation holds the OpenCode installation lock. Retry when it finishes. If it was interrupted, check the server log for the lock location.", + "settings.opencode.setup.installation_in_use": "The OpenCode executable is in use or not writable. The update was deferred before changing the installation. Retry once the executable is available.", + "settings.opencode.setup.source.path": "Executable found in the server's PATH.", + "settings.opencode.setup.source.user": "User npm installation found outside the current PATH.", + "settings.opencode.setup.source.legacy": "Legacy private CodeNomad installation. No shared installation was found.", + "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. Old private copies are preserved.", "settings.opencode.update.available": "OpenCode {version} is available.", "settings.opencode.setup.manage": "Manage OpenCode…", "settings.opencode.setup.connected": "OpenCode is connected.", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 14126fca0..45e061200 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -265,7 +265,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "Agregar", "opencodeBinarySelector.actions.browse": "Explorar binario…", "opencodeBinarySelector.actions.removeTitle": "Quitar binario", - "opencodeBinarySelector.badge.systemPath": "Usar binario del PATH del sistema", + "opencodeBinarySelector.badge.systemPath": "Primero PATH, luego instalación del usuario; copia antigua de CodeNomad como respaldo", "opencodeBinarySelector.status.checkingVersions": "Comprobando versiones…", "opencodeBinarySelector.status.checking": "Comprobando…", "opencodeBinarySelector.dialog.title": "Seleccionar binario de OpenCode", @@ -273,6 +273,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Binario de OpenCode no válido", "opencodeBinarySelector.validation.v2Required": "CodeNomad requiere OpenCode V2. Instala y selecciona el binario opencode2.", "opencodeBinarySelector.validation.alreadyValidating": "Ya se está validando", - "opencodeBinarySelector.display.systemPath": "{name} (PATH del sistema)", + "opencodeBinarySelector.display.systemPath": "{name} (automático)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 4590c9009..fa27b99a1 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Otra instalación mantiene el bloqueo de OpenCode. Reintenta cuando termine. Si se interrumpió, consulta la ubicación del bloqueo en el registro del servidor.", + "settings.opencode.setup.installation_in_use": "El ejecutable de OpenCode está en uso o no permite escritura. La actualización se aplazó antes de modificar la instalación. Reintenta cuando esté disponible.", + "settings.opencode.setup.source.path": "Ejecutable encontrado en el PATH del servidor.", + "settings.opencode.setup.source.user": "Instalación npm del usuario encontrada fuera del PATH actual.", + "settings.opencode.setup.source.legacy": "Instalación privada antigua de CodeNomad. No se encontró una instalación compartida.", + "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. Se conservan las copias privadas antiguas.", "settings.opencode.update.available": "OpenCode {version} está disponible.", "settings.opencode.setup.manage": "Gestionar OpenCode…", "settings.opencode.setup.connected": "OpenCode está conectado.", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 6a5bcd8ef..9702edd5e 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -265,7 +265,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "Ajouter", "opencodeBinarySelector.actions.browse": "Parcourir le binaire…", "opencodeBinarySelector.actions.removeTitle": "Supprimer le binaire", - "opencodeBinarySelector.badge.systemPath": "Utiliser le binaire depuis le PATH système", + "opencodeBinarySelector.badge.systemPath": "PATH prioritaire, puis installation utilisateur ; ancienne copie CodeNomad en secours", "opencodeBinarySelector.status.checkingVersions": "Vérification des versions…", "opencodeBinarySelector.status.checking": "Vérification…", "opencodeBinarySelector.dialog.title": "Sélectionner le binaire OpenCode", @@ -273,6 +273,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Binaire OpenCode invalide", "opencodeBinarySelector.validation.v2Required": "CodeNomad nécessite OpenCode V2. Installez et sélectionnez le binaire opencode2.", "opencodeBinarySelector.validation.alreadyValidating": "Validation déjà en cours", - "opencodeBinarySelector.display.systemPath": "{name} (PATH système)", + "opencodeBinarySelector.display.systemPath": "{name} (automatique)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 22a09666a..ad59a3602 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Une autre installation détient le verrou d’installation d’OpenCode. Réessayez après sa fin. Si elle a été interrompue, le journal du serveur indique l’emplacement du verrou.", + "settings.opencode.setup.installation_in_use": "L’exécutable OpenCode est utilisé ou non modifiable. La mise à jour a été différée avant toute modification de l’installation. Réessayez lorsque l’exécutable sera disponible.", + "settings.opencode.setup.source.path": "Exécutable trouvé dans le PATH du serveur.", + "settings.opencode.setup.source.user": "Installation npm utilisateur trouvée hors du PATH actuel.", + "settings.opencode.setup.source.legacy": "Ancienne installation privée de CodeNomad. Aucune installation commune trouvée.", + "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. Les anciennes copies privées sont conservées.", "settings.opencode.update.available": "OpenCode {version} est disponible.", "settings.opencode.setup.manage": "Gérer OpenCode…", "settings.opencode.setup.connected": "OpenCode est connecté.", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index f43213c5b..ea5f73c78 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -264,7 +264,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "הוסף", "opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…", "opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי", - "opencodeBinarySelector.badge.systemPath": "השתמש בקובץ בינארי מנתיב המערכת", + "opencodeBinarySelector.badge.systemPath": "עדיפות ל-PATH, אחריו התקנת המשתמש; עותק CodeNomad ישן כגיבוי", "opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…", "opencodeBinarySelector.status.checking": "בודק…", "opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode", @@ -272,6 +272,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "קובץ בינארי לא תקין של OpenCode", "opencodeBinarySelector.validation.v2Required": "CodeNomad דורש OpenCode V2. יש להתקין ולבחור בקובץ הבינארי opencode2.", "opencodeBinarySelector.validation.alreadyValidating": "כבר מאמת", - "opencodeBinarySelector.display.systemPath": "{name} (נתיב מערכת)", + "opencodeBinarySelector.display.systemPath": "{name} (אוטומטי)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a0f36c5c7..99607d2c8 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "התקנה אחרת מחזיקה בנעילת ההתקנה של OpenCode. יש לנסות שוב לאחר סיומה. אם היא נקטעה, מיקום הנעילה מופיע ביומן השרת.", + "settings.opencode.setup.installation_in_use": "קובץ ההפעלה של OpenCode נמצא בשימוש או אינו ניתן לכתיבה. העדכון נדחה לפני שינוי ההתקנה. יש לנסות שוב כשהקובץ זמין.", + "settings.opencode.setup.source.path": "קובץ ההפעלה נמצא ב-PATH של השרת.", + "settings.opencode.setup.source.user": "נמצאה התקנת npm של המשתמש מחוץ ל-PATH הנוכחי.", + "settings.opencode.setup.source.legacy": "התקנה פרטית ישנה של CodeNomad. לא נמצאה התקנה משותפת.", + "settings.opencode.setup.sharedInstall": "התקנה עבור המשתמש והגדרת PATH", + "settings.opencode.setup.sharedInstallDescription": "משתמש בהתקנת npm רגילה המשותפת ללקוחות אחרים. יש לפתוח מסוף חדש לאחר ההתקנה. העותקים הפרטיים הישנים נשמרים.", "settings.opencode.update.available": "OpenCode {version} זמין.", "settings.opencode.setup.manage": "ניהול OpenCode…", "settings.opencode.setup.connected": "OpenCode מחובר.", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index a49084529..cac41631c 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -265,7 +265,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "追加", "opencodeBinarySelector.actions.browse": "バイナリを参照…", "opencodeBinarySelector.actions.removeTitle": "バイナリを削除", - "opencodeBinarySelector.badge.systemPath": "system PATH のバイナリを使用", + "opencodeBinarySelector.badge.systemPath": "PATH、ユーザーインストールの順で優先。旧 CodeNomad コピーは予備として使用", "opencodeBinarySelector.status.checkingVersions": "バージョンを確認中…", "opencodeBinarySelector.status.checking": "確認中…", "opencodeBinarySelector.dialog.title": "OpenCode バイナリを選択", @@ -273,6 +273,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "OpenCode バイナリが無効です", "opencodeBinarySelector.validation.v2Required": "CodeNomad には OpenCode V2 が必要です。opencode2 バイナリをインストールして選択してください。", "opencodeBinarySelector.validation.alreadyValidating": "すでに検証中です", - "opencodeBinarySelector.display.systemPath": "{name} (システム PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (自動)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 07c958853..91ad741f5 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "別のインストールが OpenCode のインストールロックを保持しています。完了後に再試行してください。中断された場合は、サーバーログでロックの場所を確認してください。", + "settings.opencode.setup.installation_in_use": "OpenCode の実行ファイルは使用中、または書き込み不可です。インストールを変更する前に更新を延期しました。実行ファイルが利用可能になったら再試行してください。", + "settings.opencode.setup.source.path": "サーバーの PATH で実行ファイルが見つかりました。", + "settings.opencode.setup.source.user": "現在の PATH 外にユーザーの npm インストールが見つかりました。", + "settings.opencode.setup.source.legacy": "CodeNomad の旧プライベートインストールです。共有インストールは見つかりませんでした。", + "settings.opencode.setup.sharedInstall": "ユーザー用にインストールして PATH を設定", + "settings.opencode.setup.sharedInstallDescription": "他のクライアントと共有する標準の npm インストールを使用します。インストール後に新しいターミナルを開いてください。旧プライベートコピーは保持されます。", "settings.opencode.update.available": "OpenCode {version} が利用可能です。", "settings.opencode.setup.manage": "OpenCode を管理…", "settings.opencode.setup.connected": "OpenCode に接続しています。", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index a5d2bb424..47802e5ed 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -264,7 +264,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "थप्नुहोस्", "opencodeBinarySelector.actions.browse": "बाइनरीको लागि ब्राउज गर्नुहोस्...", "opencodeBinarySelector.actions.removeTitle": "बाइनरी हटाउनुहोस्", - "opencodeBinarySelector.badge.systemPath": "सिस्टम PATH बाट बाइनरी प्रयोग गर्नुहोस्", + "opencodeBinarySelector.badge.systemPath": "पहिले PATH, त्यसपछि प्रयोगकर्ता स्थापना; पुरानो CodeNomad प्रतिलिपि वैकल्पिक रूपमा", "opencodeBinarySelector.status.checkingVersions": "संस्करणहरू जाँच गर्दै...", "opencodeBinarySelector.status.checking": "जाँच गर्दै...", "opencodeBinarySelector.dialog.title": "OpenCode बाइनरी चयन गर्नुहोस्", @@ -272,6 +272,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "अमान्य OpenCode बाइनरी", "opencodeBinarySelector.validation.v2Required": "CodeNomad लाई OpenCode V2 आवश्यक छ। opencode2 बाइनरी स्थापना गरेर चयन गर्नुहोस्।", "opencodeBinarySelector.validation.alreadyValidating": "पहिले नै प्रमाणीकरण गर्दै", - "opencodeBinarySelector.display.systemPath": "{name} (system PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (स्वचालित)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 6a16f8af7..bec3381cd 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "अर्को स्थापनाले OpenCode स्थापना लक लिएको छ। सकिएपछि फेरि प्रयास गर्नुहोस्। बीचमै रोकिएको भए लकको स्थान सर्भर लगमा हेर्नुहोस्।", + "settings.opencode.setup.installation_in_use": "OpenCode कार्यान्वयनयोग्य फाइल प्रयोगमा छ वा लेख्न मिल्दैन। स्थापना परिवर्तन गर्नुअघि अद्यावधिक स्थगित गरियो। फाइल उपलब्ध भएपछि फेरि प्रयास गर्नुहोस्।", + "settings.opencode.setup.source.path": "सर्भरको PATH मा कार्यान्वयनयोग्य फाइल भेटियो।", + "settings.opencode.setup.source.user": "हालको PATH बाहिर प्रयोगकर्ताको npm स्थापना भेटियो।", + "settings.opencode.setup.source.legacy": "CodeNomad को पुरानो निजी स्थापना। साझा स्थापना भेटिएन।", + "settings.opencode.setup.sharedInstall": "प्रयोगकर्ताका लागि स्थापना गरी PATH मिलाउनुहोस्", + "settings.opencode.setup.sharedInstallDescription": "अन्य क्लाइन्टसँग साझा हुने मानक npm स्थापना प्रयोग गर्छ। स्थापनापछि नयाँ टर्मिनल खोल्नुहोस्। पुराना निजी प्रतिलिपिहरू सुरक्षित रहन्छन्।", "settings.opencode.update.available": "OpenCode {version} उपलब्ध छ।", "settings.opencode.setup.manage": "OpenCode व्यवस्थापन गर्नुहोस्…", "settings.opencode.setup.connected": "OpenCode जडान भएको छ।", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 016449d79..93b9e1f24 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -265,7 +265,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "Добавить", "opencodeBinarySelector.actions.browse": "Выбрать бинарник…", "opencodeBinarySelector.actions.removeTitle": "Удалить бинарник", - "opencodeBinarySelector.badge.systemPath": "Использовать бинарник из системного PATH", + "opencodeBinarySelector.badge.systemPath": "Сначала PATH, затем пользовательская установка; старая копия CodeNomad — резерв", "opencodeBinarySelector.status.checkingVersions": "Проверка версий…", "opencodeBinarySelector.status.checking": "Проверка…", "opencodeBinarySelector.dialog.title": "Выберите бинарник OpenCode", @@ -273,6 +273,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Неверный бинарник OpenCode", "opencodeBinarySelector.validation.v2Required": "Для CodeNomad требуется OpenCode V2. Установите и выберите бинарный файл opencode2.", "opencodeBinarySelector.validation.alreadyValidating": "Проверка уже выполняется", - "opencodeBinarySelector.display.systemPath": "{name} (системный PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (автоматически)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 3f81a0572..80131dd5d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Другая установка удерживает блокировку OpenCode. Повторите после её завершения. Если она прервана, путь блокировки указан в журнале сервера.", + "settings.opencode.setup.installation_in_use": "Исполняемый файл OpenCode используется или недоступен для записи. Обновление отложено до изменения установки. Повторите, когда файл станет доступен.", + "settings.opencode.setup.source.path": "Исполняемый файл найден в PATH сервера.", + "settings.opencode.setup.source.user": "Пользовательская установка npm найдена вне текущего PATH.", + "settings.opencode.setup.source.legacy": "Старая частная установка CodeNomad. Общая установка не найдена.", + "settings.opencode.setup.sharedInstall": "Установить для пользователя и настроить PATH", + "settings.opencode.setup.sharedInstallDescription": "Используется стандартная установка npm, общая с другими клиентами. После установки откройте новый терминал. Старые частные копии сохраняются.", "settings.opencode.update.available": "Доступна версия OpenCode {version}.", "settings.opencode.setup.manage": "Управление OpenCode…", "settings.opencode.setup.connected": "OpenCode подключён.", diff --git a/packages/ui/src/lib/i18n/messages/tr/instance.ts b/packages/ui/src/lib/i18n/messages/tr/instance.ts index fe50efbc6..f98efefa6 100644 --- a/packages/ui/src/lib/i18n/messages/tr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/tr/instance.ts @@ -243,7 +243,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": "Sistem PATH'teki binary'yi kullan", + "opencodeBinarySelector.badge.systemPath": "Önce PATH, sonra kullanıcı kurulumu; eski CodeNomad kopyası yedektir", "opencodeBinarySelector.status.checkingVersions": "Sürümler kontrol ediliyor…", "opencodeBinarySelector.status.checking": "Kontrol ediliyor…", "opencodeBinarySelector.dialog.title": "OpenCode Binary Seç", @@ -251,7 +251,7 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "Geçersiz OpenCode binary'si", "opencodeBinarySelector.validation.v2Required": "CodeNomad, OpenCode V2 gerektirir. opencode2 binary'sini yükleyip seçin.", "opencodeBinarySelector.validation.alreadyValidating": "Zaten doğrulanıyor", - "opencodeBinarySelector.display.systemPath": "{name} (sistem PATH)", + "opencodeBinarySelector.display.systemPath": "{name} (otomatik)", "opencodeBinarySelector.versionLabel": "v{version}", "instanceShell.backgroundProcesses.remove.message": "\"{title}\" kaldırılsın mı? Çalışan işlem durdurulacak.", "instanceShell.backgroundProcesses.remove.title": "Arka plan kabuğunu kaldır", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index 92626d061..245687eed 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "Başka bir kurulum OpenCode kurulum kilidini tutuyor. Bittiğinde yeniden deneyin. Kesintiye uğradıysa kilidin konumu için sunucu günlüğüne bakın.", + "settings.opencode.setup.installation_in_use": "OpenCode yürütülebilir dosyası kullanımda veya yazılabilir değil. Kurulum değiştirilmeden güncelleme ertelendi. Dosya kullanılabilir olduğunda yeniden deneyin.", + "settings.opencode.setup.source.path": "Yürütülebilir dosya sunucunun PATH değişkeninde bulundu.", + "settings.opencode.setup.source.user": "Geçerli PATH dışında kullanıcı npm kurulumu bulundu.", + "settings.opencode.setup.source.legacy": "Eski özel CodeNomad kurulumu. Ortak kurulum bulunamadı.", + "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. Eski özel kopyalar korunur.", "settings.opencode.update.available": "OpenCode {version} kullanılabilir.", "settings.opencode.setup.manage": "OpenCode'u yönet…", "settings.opencode.setup.connected": "OpenCode bağlı.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index c67d6d3fd..1fc7c7b50 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -265,7 +265,7 @@ export const instanceMessages = { "opencodeBinarySelector.actions.add": "添加", "opencodeBinarySelector.actions.browse": "浏览可执行文件…", "opencodeBinarySelector.actions.removeTitle": "移除可执行文件", - "opencodeBinarySelector.badge.systemPath": "使用系统 PATH 中的可执行文件", + "opencodeBinarySelector.badge.systemPath": "优先使用 PATH,其次为用户安装;旧 CodeNomad 副本仅作备用", "opencodeBinarySelector.status.checkingVersions": "正在检查版本…", "opencodeBinarySelector.status.checking": "正在检查…", "opencodeBinarySelector.dialog.title": "选择 OpenCode 可执行文件", @@ -273,6 +273,6 @@ export const instanceMessages = { "opencodeBinarySelector.validation.invalidBinary": "无效的 OpenCode 可执行文件", "opencodeBinarySelector.validation.v2Required": "CodeNomad 需要 OpenCode V2。请安装并选择 opencode2 可执行文件。", "opencodeBinarySelector.validation.alreadyValidating": "正在验证中", - "opencodeBinarySelector.display.systemPath": "{name}(系统 PATH)", + "opencodeBinarySelector.display.systemPath": "{name}(自动)", "opencodeBinarySelector.versionLabel": "v{version}", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 5813ca537..12ffd738c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -1,4 +1,11 @@ export const settingsMessages = { + "settings.opencode.setup.installation_busy": "另一个安装进程持有 OpenCode 安装锁。请在其完成后重试。如果安装已中断,请在服务器日志中查看锁的位置。", + "settings.opencode.setup.installation_in_use": "OpenCode 可执行文件正在使用或不可写。更新已在更改安装之前推迟。请在文件可用后重试。", + "settings.opencode.setup.source.path": "已在服务器 PATH 中找到可执行文件。", + "settings.opencode.setup.source.user": "已在当前 PATH 之外找到用户 npm 安装。", + "settings.opencode.setup.source.legacy": "CodeNomad 的旧版私有安装。未找到共享安装。", + "settings.opencode.setup.sharedInstall": "为用户安装并配置 PATH", + "settings.opencode.setup.sharedInstallDescription": "使用与其他客户端共享的标准 npm 安装。安装后请打开新终端。旧版私有副本将保留。", "settings.opencode.update.available": "OpenCode {version} 已可用。", "settings.opencode.setup.manage": "管理 OpenCode…", "settings.opencode.setup.connected": "OpenCode 已连接。", diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts index a0c08e8e7..128fff3d3 100644 --- a/packages/ui/src/stores/opencode-setup.ts +++ b/packages/ui/src/stores/opencode-setup.ts @@ -6,6 +6,7 @@ export const [openCodeSetupStatus, setOpenCodeSetupStatus] = createSignal() export type OpenCodeSetupAction = "install" | "start" | "restart" | "reload" export const [openCodeSetupAction, setOpenCodeSetupAction] = createSignal() export const [openCodeSetupChecking, setOpenCodeSetupChecking] = createSignal(false) @@ -65,6 +66,7 @@ export function refreshOpenCodeSetup(afterMutation = false, announce = false): P if (epoch !== generation) return setOpenCodeSetupStatus(status) setOpenCodeSetupError(false) + setOpenCodeInstallationError(undefined) if (announce) setOpenCodeSetupFeedback("checked") }).catch(() => { if (epoch === generation) setOpenCodeSetupError(true) }) .finally(() => { if (pending === request) { pending = undefined; setOpenCodeSetupChecking(false) } }) @@ -80,6 +82,7 @@ export async function runOpenCodeSetup(action: OpenCodeSetupAction) { setOpenCodeSetupAction(action) setOpenCodeSetupFeedback(undefined) setOpenCodeSetupError(false) + setOpenCodeInstallationError(undefined) try { if (action === "install") await serverApi.updateOpenCode() if (epoch !== generation) return @@ -98,8 +101,15 @@ export async function runOpenCodeSetup(action: OpenCodeSetupAction) { if (retry && status.serviceState === "ready") setOpenCodeSetupOpen(false) await retry?.() // Workspace-open retry only; never a session prompt/mutation. } - } catch { - if (epoch === generation) { await refreshOpenCodeSetup(true); setOpenCodeSetupError(true) } + } catch (error) { + if (epoch === generation) { + 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) + } + } } finally { setOpenCodeSetupBusy(false) setOpenCodeSetupAction(undefined) diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index a76ff38c3..b6fee07ce 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -47,6 +47,10 @@ test("missing installation and incompatible daemon expose different actions; res await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 }) await page.waitForFunction(() => Boolean((window as any).fixture)) await page.evaluate(() => (window as any).fixture.open()) + const installBounds = await page.getByRole("button", { name: "Install and start OpenCode" }).boundingBox() + const selectorBounds = await page.locator(".selector-input").boundingBox() + assert.ok(installBounds && selectorBounds && installBounds.y < selectorBounds.y, "recovery presents diagnosis and installation before executable selection") + if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-missing.png"), fullPage: true }) await page.getByRole("button", { name: "Install and start OpenCode" }).click() await page.getByRole("button", { name: "Restart shared service" }).waitFor() if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-restart.png") }) @@ -428,3 +432,44 @@ test("settings distinguish manual updates and failed registry checks from an up- assert.equal(await page.getByText("OpenCode 2.0.11 is available.", { exact: true }).count(), 0) } finally { await page.close() } }) + +test("private migration 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 + await page.route("**/api/**", route => { + const request = route.request() + if (request.url().endsWith("/api/storage/binaries/validate")) return route.fulfill({ json: { valid: true, version: "2.0.14" } }) + if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") { + if (++attempts === 1) return route.fulfill({ status: 409, json: { error: "installation_in_use" } }) + migrated = true + return route.fulfill({ json: { success: true, version: "2.0.15" } }) + } + if (request.url().endsWith("/api/opencode/service")) { + assert.equal(request.postDataJSON().restart, false) + connects++ + } + return route.fulfill({ json: { state: "ready", currentVersion: "2.0.15", latestVersion: "2.0.15", + updateAvailable: false, canUpgrade: !migrated, needsSharedInstallation: !migrated, + installationSource: migrated ? "path" : "legacy", binaryPath: migrated ? "C:/Users/fixture/AppData/Roaming/npm/opencode2.cmd" : "C:/Users/fixture/.local/share/codenomad/opencode/2.0.15/node_modules/@opencode/cli/bin/opencode.exe", + minimumVersion: "2.0.7", recommendedVersion: "2.0.11", versionAssessment: "untested", target: "host", + serviceState: "ready", daemonVersion: "2.0.15", canRestart: false } }) + }) + try { + await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + const install = page.getByRole("button", { name: "Installer pour l’utilisateur et configurer le PATH", exact: true }) + await install.waitFor() + await page.getByText(/Ancienne installation privée de CodeNomad/).waitFor() + assert.match(await page.locator(".selector-badge-version").innerText(), /2\.0\.15/, "selector uses the current effective version rather than its old validation cache") + assert.equal(await page.locator("main").evaluate(element => element.scrollWidth <= element.clientWidth), true) + if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-shared-migration-fr.png"), fullPage: true }) + await install.click() + await page.getByRole("alert").filter({ hasText: /L’exécutable OpenCode est utilisé/ }).waitFor() + assert.equal(connects, 0) + await install.click() + await page.getByText("Exécutable trouvé dans le PATH du serveur.", { exact: true }).waitFor() + await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]')) + assert.equal(connects, 1) + assert.equal(await install.count(), 0) + assert.equal(await page.getByRole("alert").count(), 0) + } finally { await page.close() } +}) diff --git a/scripts/test-opencode-install-native.mjs b/scripts/test-opencode-install-native.mjs index 998c3dedf..354cd5aa3 100644 --- a/scripts/test-opencode-install-native.mjs +++ b/scripts/test-opencode-install-native.mjs @@ -1,11 +1,15 @@ -// Downloads only into a fresh fixture. No global npm install or shared daemon. +// Standard npm --global install into a fresh synthetic user's prefix. +// No real user PATH/registry/profile changes and no shared daemon. import assert from "node:assert/strict" import { mkdtemp, mkdir, cp, copyFile, chmod, rm, symlink } from "node:fs/promises" import path from "node:path" import os from "node:os" -import { execFileSync } from "node:child_process" +import { execFileSync, spawn } from "node:child_process" +import { once } from "node:events" import { tsImport } from "tsx/esm/api" -const { bundledNpm, installManagedOpenCode, readManagedExecutable } = await tsImport("../packages/server/src/opencode-update/managed-installation.ts", import.meta.url) +const { bundledNpm } = await tsImport("../packages/server/src/opencode-update/managed-installation.ts", import.meta.url) +const { installSharedOpenCode, resolveDefaultInstallation, findPathOpenCode } = await tsImport("../packages/server/src/opencode-update/shared-installation.ts", import.meta.url) +const { registerUserPath } = await tsImport("../packages/server/src/opencode-update/user-path.ts", import.meta.url) const { MINIMUM_OPENCODE_VERSION } = await tsImport("../packages/server/src/opencode/runtime-support.ts", import.meta.url) const parent = path.join(os.tmpdir(), "opencode") await mkdir(parent, { recursive: true }) @@ -26,12 +30,56 @@ await mkdir(tools) // npm invokes the OS shell for package lifecycle scripts; provide that shell // explicitly without admitting /usr/bin/node or any other system Node to PATH. if (process.platform !== "win32") await symlink("/bin/sh", path.join(tools, "sh")) -Object.assign(env, { HOME: root, USERPROFILE: root, LOCALAPPDATA: root, +Object.assign(env, { HOME: root, USERPROFILE: root, LOCALAPPDATA: root, APPDATA: path.join(root, "AppData"), + ZDOTDIR: root, SHELL: process.platform === "darwin" ? "/bin/zsh" : "/bin/bash", npm_config_cache: path.join(root, "npm-cache"), npm_config_userconfig: path.join(root, "npmrc"), PATH: process.platform === "win32" ? `${process.env.SystemRoot}\\System32` : tools }) try { - const binary = await installManagedOpenCode(MINIMUM_OPENCODE_VERSION, { root: path.join(root, "install"), node, env }) - assert.equal(readManagedExecutable(path.join(root, "install")), binary) + let registered = false + const options = { home: root, node, env, registerPath: directory => registerUserPath(directory, { + home: root, env, registerWindowsPath: async () => { registered = true }, + }) } + const binary = await installSharedOpenCode(MINIMUM_OPENCODE_VERSION, options) + assert.equal(resolveDefaultInstallation(options).source, "path") + assert.equal(resolveDefaultInstallation(options).path, findPathOpenCode(options)) + if (process.platform === "win32") assert.equal(registered, true) assert.match(execFileSync(binary, ["--version"], { encoding: "utf8", env }), new RegExp(MINIMUM_OPENCODE_VERSION.replaceAll(".", "\\."))) - console.log(`PASS: native npm installation with isolated bundled Node, no system Node PATH, verified executable (${MINIMUM_OPENCODE_VERSION})`) + const terminalVersion = process.platform === "win32" + ? execFileSync(process.env.ComSpec || "C:\\Windows\\System32\\cmd.exe", ["/d", "/s", "/c", "opencode2 --version"], { cwd: root, encoding: "utf8", env }) + : execFileSync("opencode2", ["--version"], { cwd: root, encoding: "utf8", env }) + assert.match(terminalVersion, new RegExp(MINIMUM_OPENCODE_VERSION.replaceAll(".", "\\."))) + const commandDirectory = path.dirname(findPathOpenCode(options)) + for (const name of ["opencode", "opencode2"]) { + for (const extension of process.platform === "win32" ? ["", ".cmd", ".ps1"] : [""]) { + await rm(path.join(commandDirectory, name + extension), { force: true }) + } + } + await installSharedOpenCode(MINIMUM_OPENCODE_VERSION, options) + assert.ok(findPathOpenCode(options), "same-version npm install repairs missing terminal commands") + if (process.platform === "win32") { + // A mapped test executable reproduces the Windows lock without ever starting + // OpenCode's daemon, even in this synthetic home. + const backup = path.join(root, "original-opencode.exe") + await copyFile(binary, backup) + await copyFile(node, binary) + const child = spawn(binary, ["-e", "console.log('ready'); setInterval(() => {}, 1000)"], { env, stdio: ["ignore", "pipe", "pipe"] }) + try { + await once(child.stdout, "data") + let executed = false + await assert.rejects(installSharedOpenCode("2.0.11", { ...options, + probe: async () => ({ valid: true, version: MINIMUM_OPENCODE_VERSION }), + execute: async () => { executed = true }, + }), error => error.code === "installation_in_use") + assert.equal(executed, false, "npm must not retire the live package") + assert.equal(child.exitCode, null, "installation never stops a running executable") + } finally { + const closed = once(child, "close") + child.kill() + await closed + await copyFile(backup, binary) + } + assert.match(execFileSync(binary, ["--version"], { encoding: "utf8", env }), new RegExp(MINIMUM_OPENCODE_VERSION.replaceAll(".", "\\."))) + console.log("PASS: Windows live-executable update deferred before npm; original installation retained") + } + console.log(`PASS: shared user npm installation with isolated bundled Node, no system Node PATH; backend and terminal both resolve ${MINIMUM_OPENCODE_VERSION}`) } finally { await rm(root, { recursive: true, force: true }) } From c6a4b25806e827e3bda75f7ed68c2d6848253e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 23 Sep 2026 14:46:27 +0200 Subject: [PATCH 2/5] fix(opencode): recognize historical official npm launchers Read the package's published bin map and verify the real launcher target before choosing the automatic command. This avoids the retired opencode2.cjs alias in 2.0.0 and accepts beta installations that published opencode2.exe, so existing users can migrate to a common stable installation. Probe and preflight the historical executable while replacing the package; allow known beta versions to upgrade without downgrading current stable installs. Regress historical launcher fixtures and confirm real beta/2.0.0 npm installations migrate to 2.0.7 in isolated temporary prefixes without touching the shared daemon or real PATH. --- dev-docs/OPENCODE_V2_POST_BETA.md | 5 ++ .../shared-installation.test.ts | 23 +++++++- .../opencode-update/shared-installation.ts | 54 +++++++++++++++---- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 2e590b9b1..2684c79e5 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -57,6 +57,11 @@ the effective path and source; “automatic” replaces the misleading “system label. Recovery puts diagnosis and installation before executable selection; Preferences keeps executable selection first, inline actions, collapsed version details/troubleshooting and logs last. +Official npm packages from the beta and 2.0.0 transition advertised different +launcher targets: discovery checks their published `bin` map and real launcher +target, choosing `opencode` when `opencode2` is only the retired `.cjs` alias. +Known historical beta versions can migrate to a stable target without treating +their version label as an unrecognized custom build. Default host installation uses bundled Node/npm to run a standard global npm installation of `@opencode/cli`. Existing writable npm installations on PATH are diff --git a/packages/server/src/opencode-update/shared-installation.test.ts b/packages/server/src/opencode-update/shared-installation.test.ts index 4fa6c4321..5bfce5b0d 100644 --- a/packages/server/src/opencode-update/shared-installation.test.ts +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -13,7 +13,7 @@ async function npmFixture(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" })) + await writeFile(path.join(path.dirname(binary), "..", "package.json"), JSON.stringify({ name: "@opencode/cli", bin: { opencode2: "./bin/opencode.exe" } })) const bin = npmCommandDirectory(prefix) await mkdir(bin, { recursive: true }) const command = path.join(bin, process.platform === "win32" ? "opencode2.cmd" : "opencode2") @@ -115,6 +115,27 @@ test("standalone opencode commands and custom npm prefixes are discovered withou } finally { await rm(home, { recursive: true, force: true }) } }) +test("historical npm launchers select the real executable and allow stable migration", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-history-")) + try { + for (const [version, name, oldBinary] of [["0.0.0-beta-19275", "opencode2", "opencode2.exe"], ["2.0.0", "opencode", "opencode.exe"]]) { + const prefix = path.join(home, version) + const binary = path.join(prefix, "node_modules", "@opencode", "cli", "bin", oldBinary) + await mkdir(path.dirname(binary), { recursive: true }) + await writeFile(binary, version) + await writeFile(path.join(path.dirname(binary), "..", "package.json"), JSON.stringify({ name: "@opencode/cli", bin: { + opencode2: name === "opencode2" ? `./bin/${oldBinary}` : "./bin/opencode2.cjs", opencode: "./bin/opencode.exe", + } })) + const command = path.join(prefix, `${name}.cmd`) + await writeFile(command, `@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\${oldBinary}" %*\r\n`) + if (name === "opencode") await writeFile(path.join(prefix, "opencode2.cmd"), "@echo off\r\nexit /b 1\r\n") + const host = { home, env: { PATH: prefix, APPDATA: home } } + assert.equal(resolveDefaultInstallation(host).path, command) + assert.equal(sharedInstallPrefix(host), prefix) + } + } finally { await rm(home, { recursive: true, force: true }) } +}) + test("non-npm PATH executables are reused without allowing npm to shadow them", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-custom-")) try { diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts index bf10a5023..f00bbadee 100644 --- a/packages/server/src/opencode-update/shared-installation.ts +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -31,6 +31,32 @@ export function npmExecutable(prefix: string, platform = process.platform): stri return path.join(prefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", "@opencode", "cli", "bin", "opencode.exe") } +function npmPackage(prefix: string, platform: NodeJS.Platform) { + try { + const file = path.join(path.dirname(npmExecutable(prefix, platform)), "..", "package.json") + const manifest = JSON.parse(readFileSync(file, "utf8")) as { name?: string; bin?: Record } + return manifest.name === "@opencode/cli" ? manifest : undefined + } catch { return undefined } +} + +function npmCommand(prefix: string, platform: NodeJS.Platform): { command: string; binary: string } | undefined { + const manifest = npmPackage(prefix, platform) + if (!manifest?.bin) return undefined + const directory = npmCommandDirectory(prefix, platform) + const packageRoot = path.join(path.dirname(npmExecutable(prefix, platform)), "..") + for (const name of ["opencode2", "opencode"]) { + const relative = manifest.bin[name] + if (typeof relative !== "string" || !/^\.\/bin\/[^/\\]+\.exe$/.test(relative)) continue + const binary = path.resolve(packageRoot, relative) + const command = path.join(directory, platform === "win32" ? `${name}.cmd` : name) + try { + if (!statSync(binary).isFile()) continue + const resolved = platform === "win32" ? buildSpawnSpec(command, [], { platform }).command : realpathSync(command) + if (realpathSync(resolved) === realpathSync(binary)) return { command, binary } + } catch { /* Invalid or missing launcher; try the other official name. */ } + } +} + export function findPathOpenCode(host: InstallationHost = {}): string | undefined { const env = host.env ?? process.env const platform = host.platform ?? process.platform @@ -39,6 +65,12 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine for (const entry of (env[key ?? "PATH"] || "").split(platform === "win32" ? ";" : ":")) { const directory = entry.replace(/^"|"$/g, "") if (!directory || !path.isAbsolute(directory)) continue + const prefix = platform === "win32" ? directory : path.dirname(directory) + if (npmPackage(prefix, platform)) { + const published = npmCommand(prefix, platform) + if (published) return published.command + continue + } for (const name of ["opencode2", "opencode"]) for (const extension of extensions) { const candidate = path.join(directory, `${name}${extension}`) try { @@ -53,7 +85,8 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine export function resolveDefaultInstallation(host: InstallationHost = {}): { path: string; source?: "path" | "user" | "legacy" } { const command = findPathOpenCode(host) if (command) return { path: command, source: "path" } - const binary = npmExecutable(userNpmPrefix(host), host.platform) + const prefix = userNpmPrefix(host) + const binary = npmCommand(prefix, host.platform ?? process.platform)?.binary ?? npmExecutable(prefix, host.platform) if (existsSync(binary)) return { path: binary, source: "user" } // Migration fallback only: never override an installation shared through PATH. const legacy = readManagedExecutable(path.join(host.home ?? os.homedir(), ".local", "share", "codenomad", "opencode")) @@ -69,12 +102,10 @@ export function sharedInstallPrefix(host: InstallationHost = {}): string | undef 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)) - const binary = npmExecutable(prefix, platform) try { - const manifest = JSON.parse(readFileSync(path.join(path.dirname(binary), "..", "package.json"), "utf8")) - if (manifest.name !== "@opencode/cli") return undefined - const resolved = platform === "win32" ? buildSpawnSpec(command, [], { platform, env: host.env }).command : realpathSync(command) - if (realpathSync(resolved) !== realpathSync(binary)) return undefined + const published = npmCommand(prefix, platform) + if (!published) return undefined + if (realpathSync(command) !== realpathSync(published.command)) return undefined accessSync(prefix, constants.W_OK) return prefix } catch { return undefined } @@ -97,15 +128,16 @@ export async function installSharedOpenCode(version: string, options: Installati const binary = npmExecutable(prefix, platform) const probe = options.probe ?? probeBinaryVersionAsync return withInstallationLock(prefix, async () => { - const existing = await probe(binary) - if (existing.valid && (!existing.version || !/^\d+\.\d+\.\d+$/.test(existing.version))) { + 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))) { throw new Error("Cannot replace an unverified OpenCode version automatically") } - const target = existing.valid && existing.version && compareVersionStrings(existing.version, version) > 0 ? existing.version : version + const target = existing.valid && existing.version && /^\d+\.\d+\.\d+$/.test(existing.version) && compareVersionStrings(existing.version, version) > 0 ? existing.version : version const directory = npmCommandDirectory(prefix, platform) const commandHost = { ...options, env: { PATH: directory, PATHEXT: ".EXE;.CMD;.BAT" } } - if (!existing.valid || existing.version !== target || !findPathOpenCode(commandHost)) { - await assertExecutableWritable(binary, platform) + if (!existing.valid || existing.version !== target || !npmCommand(prefix, platform) || oldBinary !== binary) { + await assertExecutableWritable(oldBinary, platform) 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] || ""}` From ef52ea0c54f281eac669b04f299fa50d83dc510e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 23 Sep 2026 15:06:55 +0200 Subject: [PATCH 3/5] fix(opencode): honor terminal executable precedence beside npm shims Discover commands in actual PATH/PATHEXT order rather than giving a package's npm shim implicit priority over a standalone executable in the same directory. Verify npm launcher identity only when that shim is the selected command; POSIX sibling directories cannot borrow a prefix's package metadata. Make historical launcher fixtures portable across Windows, Linux and macOS, cover same-prefix standalone precedence and neighboring POSIX directories, and retain native Windows migration and bundled npm installation checks. Validation: 33 targeted server tests, server typecheck, isolated npm Windows fixture and diff check. --- dev-docs/OPENCODE_V2_POST_BETA.md | 4 ++ .../shared-installation.test.ts | 50 ++++++++++++++++--- .../opencode-update/shared-installation.ts | 39 ++++++++------- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 2684c79e5..6be27875c 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -62,6 +62,10 @@ launcher targets: discovery checks their published `bin` map and real launcher target, choosing `opencode` when `opencode2` is only the retired `.cjs` alias. Known historical beta versions can migrate to a stable target without treating their version label as an unrecognized custom build. +Discovery preserves terminal PATH/PATHEXT ordering even when a standalone +executable shares the npm command directory; such an executable remains +user-managed. On POSIX, only the prefix's actual `bin` directory is treated as +an npm command directory, not a neighboring folder. Default host installation uses bundled Node/npm to run a standard global npm installation of `@opencode/cli`. Existing writable npm installations on PATH are diff --git a/packages/server/src/opencode-update/shared-installation.test.ts b/packages/server/src/opencode-update/shared-installation.test.ts index 5bfce5b0d..fe3174cb8 100644 --- a/packages/server/src/opencode-update/shared-installation.test.ts +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -120,22 +120,60 @@ test("historical npm launchers select the real executable and allow stable migra try { for (const [version, name, oldBinary] of [["0.0.0-beta-19275", "opencode2", "opencode2.exe"], ["2.0.0", "opencode", "opencode.exe"]]) { const prefix = path.join(home, version) - const binary = path.join(prefix, "node_modules", "@opencode", "cli", "bin", oldBinary) + const binary = path.join(path.dirname(npmExecutable(prefix)), oldBinary) await mkdir(path.dirname(binary), { recursive: true }) - await writeFile(binary, version) + await writeFile(binary, version, { mode: 0o755 }) await writeFile(path.join(path.dirname(binary), "..", "package.json"), JSON.stringify({ name: "@opencode/cli", bin: { opencode2: name === "opencode2" ? `./bin/${oldBinary}` : "./bin/opencode2.cjs", opencode: "./bin/opencode.exe", } })) - const command = path.join(prefix, `${name}.cmd`) - await writeFile(command, `@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\${oldBinary}" %*\r\n`) - if (name === "opencode") await writeFile(path.join(prefix, "opencode2.cmd"), "@echo off\r\nexit /b 1\r\n") - const host = { home, env: { PATH: prefix, APPDATA: home } } + const directory = npmCommandDirectory(prefix) + await mkdir(directory, { recursive: true }) + const command = path.join(directory, process.platform === "win32" ? `${name}.cmd` : name) + if (process.platform === "win32") { + await writeFile(command, `@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\${oldBinary}" %*\r\n`) + if (name === "opencode") await writeFile(path.join(directory, "opencode2.cmd"), "@echo off\r\nexit /b 1\r\n") + } else { + await symlink(binary, command) + if (name === "opencode") { + const retired = path.join(path.dirname(binary), "opencode2.cjs") + await writeFile(retired, "retired", { mode: 0o755 }) + await symlink(retired, path.join(directory, "opencode2")) + } + } + const host = { home, env: { PATH: directory, APPDATA: home } } assert.equal(resolveDefaultInstallation(host).path, command) assert.equal(sharedInstallPrefix(host), prefix) } } finally { await rm(home, { recursive: true, force: true }) } }) +test("PATH executable precedence wins over npm shims in the same prefix", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-precedence-")) + try { + const prefix = path.join(home, "npm") + const directory = npmCommandDirectory(prefix) + await npmFixture(prefix, "2.0.3") + const standalone = path.join(directory, process.platform === "win32" ? "opencode2.exe" : "opencode") + if (process.platform === "win32") await writeFile(standalone, "standalone") + else { + await rm(standalone, { force: true }) + await writeFile(standalone, "standalone", { mode: 0o755 }) + } + const host = { home, env: { PATH: directory } } + if (process.platform === "win32") { + assert.equal(resolveDefaultInstallation(host).path, standalone) + assert.equal(sharedInstallPrefix(host), undefined) + } else { + const sibling = path.join(prefix, "sibling") + 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) + } + } finally { await rm(home, { recursive: true, force: true }) } +}) + test("non-npm PATH executables are reused without allowing npm to shadow them", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-custom-")) try { diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts index f00bbadee..73e4986aa 100644 --- a/packages/server/src/opencode-update/shared-installation.ts +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -39,21 +39,27 @@ function npmPackage(prefix: string, platform: NodeJS.Platform) { } catch { return undefined } } -function npmCommand(prefix: string, platform: NodeJS.Platform): { command: string; binary: string } | undefined { +function npmLauncher(command: string, prefix: string, platform: NodeJS.Platform): string | undefined { const manifest = npmPackage(prefix, platform) if (!manifest?.bin) return undefined - const directory = npmCommandDirectory(prefix, platform) + const name = path.basename(command).replace(platform === "win32" ? /\.cmd$/i : /$^/, "") + const relative = manifest.bin[name] + if (typeof relative !== "string" || !/^\.\/bin\/[^/\\]+\.exe$/.test(relative)) return undefined const packageRoot = path.join(path.dirname(npmExecutable(prefix, platform)), "..") + const binary = path.resolve(packageRoot, relative) + try { + if (!statSync(binary).isFile()) return undefined + const resolved = platform === "win32" ? buildSpawnSpec(command, [], { platform }).command : realpathSync(command) + return realpathSync(resolved) === realpathSync(binary) ? binary : undefined + } catch { return undefined } +} + +function npmCommand(prefix: string, platform: NodeJS.Platform): { command: string; binary: string } | undefined { + const directory = npmCommandDirectory(prefix, platform) for (const name of ["opencode2", "opencode"]) { - const relative = manifest.bin[name] - if (typeof relative !== "string" || !/^\.\/bin\/[^/\\]+\.exe$/.test(relative)) continue - const binary = path.resolve(packageRoot, relative) const command = path.join(directory, platform === "win32" ? `${name}.cmd` : name) - try { - if (!statSync(binary).isFile()) continue - const resolved = platform === "win32" ? buildSpawnSpec(command, [], { platform }).command : realpathSync(command) - if (realpathSync(resolved) === realpathSync(binary)) return { command, binary } - } catch { /* Invalid or missing launcher; try the other official name. */ } + const binary = npmLauncher(command, prefix, platform) + if (binary) return { command, binary } } } @@ -66,16 +72,15 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine const directory = entry.replace(/^"|"$/g, "") if (!directory || !path.isAbsolute(directory)) continue const prefix = platform === "win32" ? directory : path.dirname(directory) - if (npmPackage(prefix, platform)) { - const published = npmCommand(prefix, platform) - if (published) return published.command - continue - } + const inNpmBin = path.resolve(directory) === path.resolve(npmCommandDirectory(prefix, platform)) for (const name of ["opencode2", "opencode"]) for (const extension of extensions) { const candidate = path.join(directory, `${name}${extension}`) try { if (!statSync(candidate).isFile()) continue accessSync(candidate, platform === "win32" ? constants.F_OK : constants.X_OK) + if (inNpmBin && npmPackage(prefix, platform) && (platform !== "win32" || extension === ".cmd")) { + if (!npmLauncher(candidate, prefix, platform)) continue + } return candidate } catch { /* Continue in PATH order. */ } } @@ -103,9 +108,7 @@ export function sharedInstallPrefix(host: InstallationHost = {}): string | undef if (!command) return userPrefix const prefix = platform === "win32" ? path.dirname(command) : path.dirname(path.dirname(command)) try { - const published = npmCommand(prefix, platform) - if (!published) return undefined - if (realpathSync(command) !== realpathSync(published.command)) return undefined + if (!npmLauncher(command, prefix, platform)) return undefined accessSync(prefix, constants.W_OK) return prefix } catch { return undefined } From 5ce224adc1368a558f2538e2770257d49523ee00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 23 Sep 2026 15:21:41 +0200 Subject: [PATCH 4/5] fix(opencode): retain standalone POSIX command within npm prefixes Only skip an OpenCode command when it actually resolves to the package's retired .cjs alias. A replaced POSIX npm symlink can be an independently installed, newer executable; keep terminal PATH order and classify it as user-managed rather than selecting the other npm shim and offering an unsafe npm update. Expand same-prefix precedence coverage for POSIX alongside Windows and document the supported launcher transition. Verify targeted server tests, server typecheck, isolated native Windows npm installation and diff integrity. --- dev-docs/OPENCODE_V2_POST_BETA.md | 5 ++++- .../opencode-update/shared-installation.test.ts | 10 +++++----- .../src/opencode-update/shared-installation.ts | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 6be27875c..db2478ff6 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -65,7 +65,10 @@ their version label as an unrecognized custom build. Discovery preserves terminal PATH/PATHEXT ordering even when a standalone executable shares the npm command directory; such an executable remains user-managed. On POSIX, only the prefix's actual `bin` directory is treated as -an npm command directory, not a neighboring folder. +an npm command directory, not a neighboring folder. A standalone executable +replacing a POSIX npm symlink is still selected in PATH order and remains +user-managed; only an actual symlink to the package's retired `.cjs` alias is +skipped in favor of the working `opencode` command. Default host installation uses bundled Node/npm to run a standard global npm installation of `@opencode/cli`. Existing writable npm installations on PATH are diff --git a/packages/server/src/opencode-update/shared-installation.test.ts b/packages/server/src/opencode-update/shared-installation.test.ts index fe3174cb8..c155ce643 100644 --- a/packages/server/src/opencode-update/shared-installation.test.ts +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -153,17 +153,17 @@ test("PATH executable precedence wins over npm shims in the same prefix", async const prefix = path.join(home, "npm") const directory = npmCommandDirectory(prefix) await npmFixture(prefix, "2.0.3") - const standalone = path.join(directory, process.platform === "win32" ? "opencode2.exe" : "opencode") + const standalone = path.join(directory, process.platform === "win32" ? "opencode2.exe" : "opencode2") if (process.platform === "win32") await writeFile(standalone, "standalone") else { await rm(standalone, { force: true }) await writeFile(standalone, "standalone", { mode: 0o755 }) + await symlink(npmExecutable(prefix), path.join(directory, "opencode")) } const host = { home, env: { PATH: directory } } - if (process.platform === "win32") { - assert.equal(resolveDefaultInstallation(host).path, standalone) - assert.equal(sharedInstallPrefix(host), undefined) - } else { + assert.equal(resolveDefaultInstallation(host).path, standalone) + assert.equal(sharedInstallPrefix(host), undefined) + if (process.platform !== "win32") { const sibling = path.join(prefix, "sibling") await mkdir(sibling) const unrelated = path.join(sibling, "opencode2") diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts index 73e4986aa..35fc6f781 100644 --- a/packages/server/src/opencode-update/shared-installation.ts +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -54,6 +54,16 @@ function npmLauncher(command: string, prefix: string, platform: NodeJS.Platform) } catch { return undefined } } +function retiredPosixAlias(command: string, prefix: string, platform: NodeJS.Platform): boolean { + if (platform === "win32") return false + const relative = npmPackage(prefix, platform)?.bin?.[path.basename(command)] + if (typeof relative !== "string" || !/^\.\/bin\/[^/\\]+\.cjs$/.test(relative)) return false + try { + const target = path.resolve(path.dirname(npmExecutable(prefix, platform)), "..", relative) + return realpathSync(command) === realpathSync(target) + } catch { return false } +} + function npmCommand(prefix: string, platform: NodeJS.Platform): { command: string; binary: string } | undefined { const directory = npmCommandDirectory(prefix, platform) for (const name of ["opencode2", "opencode"]) { @@ -78,8 +88,9 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine try { if (!statSync(candidate).isFile()) continue accessSync(candidate, platform === "win32" ? constants.F_OK : constants.X_OK) - if (inNpmBin && npmPackage(prefix, platform) && (platform !== "win32" || extension === ".cmd")) { - if (!npmLauncher(candidate, prefix, platform)) continue + if (inNpmBin && npmPackage(prefix, platform)) { + if (platform === "win32" && extension === ".cmd" && !npmLauncher(candidate, prefix, platform)) continue + if (retiredPosixAlias(candidate, prefix, platform)) continue } return candidate } catch { /* Continue in PATH order. */ } From e33a6a16da2f79d7fc76520500451c604b02b81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 23 Sep 2026 15:40:35 +0200 Subject: [PATCH 5/5] fix(opencode): preserve custom Windows npm-prefix wrappers Treat a Windows npm-prefix command as the retired rename alias only when its package manifest advertises the .cjs entry and its bounded command script positively invokes that entry. Respect a customized opencode2.cmd in PATH instead of silently falling through to the older opencode.cmd shim or authorizing npm to replace it. Use a representative historical .cjs command fixture, add a regression for a custom Windows wrapper alongside an npm alias, and document the selection boundary. Targeted tests, server typecheck, native Windows installation and diff integrity pass. --- dev-docs/OPENCODE_V2_POST_BETA.md | 5 +++- .../shared-installation.test.ts | 25 ++++++++++++++++++- .../opencode-update/shared-installation.ts | 15 +++++++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index db2478ff6..346f7d4a3 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -68,7 +68,10 @@ user-managed. On POSIX, only the prefix's actual `bin` directory is treated as an npm command directory, not a neighboring folder. A standalone executable replacing a POSIX npm symlink is still selected in PATH order and remains user-managed; only an actual symlink to the package's retired `.cjs` alias is -skipped in favor of the working `opencode` command. +skipped in favor of the working `opencode` command. On Windows, the same +retired alias is skipped only when the npm `.cmd` script positively invokes +the package's `.cjs` target; a customized `.cmd` wrapper keeps PATH priority +and cannot be overwritten through the automatic npm updater. Default host installation uses bundled Node/npm to run a standard global npm installation of `@opencode/cli`. Existing writable npm installations on PATH are diff --git a/packages/server/src/opencode-update/shared-installation.test.ts b/packages/server/src/opencode-update/shared-installation.test.ts index c155ce643..109579da9 100644 --- a/packages/server/src/opencode-update/shared-installation.test.ts +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -131,7 +131,10 @@ test("historical npm launchers select the real executable and allow stable migra const command = path.join(directory, process.platform === "win32" ? `${name}.cmd` : name) if (process.platform === "win32") { await writeFile(command, `@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\${oldBinary}" %*\r\n`) - if (name === "opencode") await writeFile(path.join(directory, "opencode2.cmd"), "@echo off\r\nexit /b 1\r\n") + if (name === "opencode") { + await writeFile(path.join(path.dirname(binary), "opencode2.cjs"), "retired") + await writeFile(path.join(directory, "opencode2.cmd"), '@echo off\r\nnode "%~dp0\\node_modules\\@opencode\\cli\\bin\\opencode2.cjs" %*\r\n') + } } else { await symlink(binary, command) if (name === "opencode") { @@ -174,6 +177,26 @@ test("PATH executable precedence wins over npm shims in the same prefix", async } finally { await rm(home, { recursive: true, force: true }) } }) +test("custom Windows command wrapper in an npm prefix wins over the package alias", async () => { + if (process.platform !== "win32") return + const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-wrapper-")) + try { + const prefix = path.join(home, "npm") + await npmFixture(prefix, "2.0.3") + await writeFile(path.join(path.dirname(npmExecutable(prefix)), "..", "package.json"), JSON.stringify({ + name: "@opencode/cli", bin: { opencode2: "./bin/opencode.exe", opencode: "./bin/opencode.exe" }, + })) + await writeFile(path.join(prefix, "opencode.cmd"), '@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n') + const standalone = path.join(home, "standalone.exe") + 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 } } + assert.equal(resolveDefaultInstallation(host).path, command) + assert.equal(sharedInstallPrefix(host), undefined) + } finally { await rm(home, { recursive: true, force: true }) } +}) + test("non-npm PATH executables are reused without allowing npm to shadow them", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "shared-opencode-custom-")) try { diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts index 35fc6f781..2e299e9d5 100644 --- a/packages/server/src/opencode-update/shared-installation.ts +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -54,12 +54,18 @@ function npmLauncher(command: string, prefix: string, platform: NodeJS.Platform) } catch { return undefined } } -function retiredPosixAlias(command: string, prefix: string, platform: NodeJS.Platform): boolean { - if (platform === "win32") return false - const relative = npmPackage(prefix, platform)?.bin?.[path.basename(command)] +function retiredNpmAlias(command: string, prefix: string, platform: NodeJS.Platform): boolean { + const name = platform === "win32" ? path.basename(command).replace(/\.cmd$/i, "") : path.basename(command) + const relative = npmPackage(prefix, platform)?.bin?.[name] if (typeof relative !== "string" || !/^\.\/bin\/[^/\\]+\.cjs$/.test(relative)) return false try { const target = path.resolve(path.dirname(npmExecutable(prefix, platform)), "..", relative) + if (platform === "win32") { + const script = readFileSync(command, "utf8") + if (script.length > 64 * 1024) return false + const escaped = path.basename(target).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + return statSync(target).isFile() && new RegExp(`["'](?:%dp0%|%~dp0)[\\\\/]node_modules[\\\\/]@opencode[\\\\/]cli[\\\\/]bin[\\\\/]${escaped}["']\\s+%\\*`, "i").test(script) + } return realpathSync(command) === realpathSync(target) } catch { return false } } @@ -89,8 +95,7 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine if (!statSync(candidate).isFile()) continue accessSync(candidate, platform === "win32" ? constants.F_OK : constants.X_OK) if (inNpmBin && npmPackage(prefix, platform)) { - if (platform === "win32" && extension === ".cmd" && !npmLauncher(candidate, prefix, platform)) continue - if (retiredPosixAlias(candidate, prefix, platform)) continue + if (retiredNpmAlias(candidate, prefix, platform)) continue } return candidate } catch { /* Continue in PATH order. */ }