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..346f7d4a3 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,65 @@ 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. +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. +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. 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. 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 +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..109579da9 --- /dev/null +++ b/packages/server/src/opencode-update/shared-installation.test.ts @@ -0,0 +1,236 @@ +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", 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") + 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("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(path.dirname(npmExecutable(prefix)), oldBinary) + 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", bin: { + opencode2: name === "opencode2" ? `./bin/${oldBinary}` : "./bin/opencode2.cjs", opencode: "./bin/opencode.exe", + } })) + 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(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") { + 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" : "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 } } + 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") + 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("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 { + 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..2e299e9d5 --- /dev/null +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -0,0 +1,176 @@ +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") +} + +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 npmLauncher(command: string, prefix: string, platform: NodeJS.Platform): string | undefined { + const manifest = npmPackage(prefix, platform) + if (!manifest?.bin) return undefined + 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 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 } +} + +function npmCommand(prefix: string, platform: NodeJS.Platform): { command: string; binary: string } | undefined { + const directory = npmCommandDirectory(prefix, platform) + for (const name of ["opencode2", "opencode"]) { + const command = path.join(directory, platform === "win32" ? `${name}.cmd` : name) + const binary = npmLauncher(command, prefix, platform) + if (binary) return { command, binary } + } +} + +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 + const prefix = platform === "win32" ? directory : path.dirname(directory) + 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)) { + if (retiredNpmAlias(candidate, prefix, platform)) continue + } + 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 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")) + 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)) + try { + if (!npmLauncher(command, prefix, platform)) 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 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 && /^\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 || !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] || ""}` + 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 }) }