diff --git a/dev-docs/OPENCODE_FIRST_INSTALL_RECOVERY.md b/dev-docs/OPENCODE_FIRST_INSTALL_RECOVERY.md new file mode 100644 index 000000000..52a4415bb --- /dev/null +++ b/dev-docs/OPENCODE_FIRST_INSTALL_RECOVERY.md @@ -0,0 +1,53 @@ +# Windows first-install recovery + +## Captured 0.20.0 failure + +On a Windows 11 x64 machine without Git or a preinstalled OpenCode, the initial +installation invoked the packaged Node 24.20.0 / npm 11.19.0 runtime to install +`@opencode/cli@2.0.16` in the user's standard npm prefix. + +The npm log reported failed optional dependencies for both Windows x64 binary +packages, followed by postinstall exit 1 and an EPERM cleanup warning. Cleanup +removed the CLI package but left its generated `opencode.cmd` and `opencode2.cmd` +launchers. The original log does not identify why the binary dependencies failed; +do not attribute this to Git, CPU support, antivirus, or connectivity without +additional evidence. + +CodeNomad then reported the executable as missing, but `sharedInstallPrefix` +could no longer verify npm ownership through the removed package manifest and +target executable. `canUpgrade` became false, hiding the install button. A +subsequent successful status refresh also erased the preceding action error. + +## Recovery boundaries + +`incomplete-installation.ts` recognizes only npm's complete generated Windows +native-executable shim, pointing at the missing canonical OpenCode executable in +the selected user npm prefix. Additional commands, custom wrappers, other prefixes, +and existing unverified executables do not qualify. Detection is read-only. + +This narrow residual state remains eligible for an explicit installation retry. +If the orphan shim is already on PATH, discovery probes the missing target +instead of executing the broken wrapper and classifying it as an invalid binary. +The installer retains its existing lock, write preflight, version verification, +launcher verification and explicit PATH registration. It does not automatically +retry npm or restart the service after a failure. + +The setup store retains action failures across successful status reads and dialog +reopening. A new explicit action or executable-selection change clears them. + +## Validation + +- Regression fixture reproduces both pre-PATH and on-PATH orphan-launcher states + without a package manifest, and checks that install availability survives. +- Explicit repair test exercises updater status, installer admission and final + installed-version verification; custom-wrapper/foreign-prefix tests protect + existing installation authority. +- Browser recovery regression checks that refresh and dialog reopening do not + erase the preceding installation error. +- On the affected machine, an isolated download and `--version` invocation + succeeded without Git. After explicit user authorization, the packaged npm + successfully repaired the standard user installation; both the executable + and `opencode2.cmd` reported 2.0.16. User PATH registration was then completed. + +The later success establishes recoverability, not the cause of the earlier +optional-dependency failure. Logs and remote access credentials are not committed. diff --git a/packages/server/src/opencode-update/incomplete-installation.test.ts b/packages/server/src/opencode-update/incomplete-installation.test.ts new file mode 100644 index 000000000..69113c41a --- /dev/null +++ b/packages/server/src/opencode-update/incomplete-installation.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { isIncompleteWindowsNpmShim } from "./incomplete-installation" +import { installSharedOpenCode, npmExecutable, resolveDefaultInstallation, sharedInstallPrefix } from "./shared-installation" +import { OpenCodeUpdateService } from "./service" + +const shim = '@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n' + +test("failed npm installation remains repairable with orphan shims and no package manifest", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-incomplete-")) + try { + const prefix = path.join(root, "npm") + await mkdir(prefix) + const command = path.join(prefix, "opencode2.cmd") + await writeFile(command, shim) + await writeFile(path.join(prefix, "opencode.cmd"), shim) + const host = { home: root, platform: "win32" as const, env: { PATH: "", npm_config_prefix: prefix } } + assert.equal(sharedInstallPrefix(host), prefix, "initial failure before PATH registration must allow retry") + host.env.PATH = prefix + assert.equal(sharedInstallPrefix(host), prefix) + assert.deepEqual(resolveDefaultInstallation(host), { path: npmExecutable(prefix, "win32"), source: "user" }, + "probe the missing executable instead of reporting an invalid cmd wrapper") + const binary = npmExecutable(prefix, "win32") + await mkdir(path.dirname(binary), { recursive: true }) + await writeFile(binary, "existing unverified executable") + assert.equal(sharedInstallPrefix(host), undefined, "existing unverified binaries are not incomplete installs") + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test("an explicit retry repairs leftover launchers and restores update status", { skip: process.platform !== "win32" }, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-retry-")) + try { + const prefix = path.join(root, "npm") + await mkdir(prefix) + await writeFile(path.join(prefix, "opencode2.cmd"), shim) + const host = { home: root, env: { PATH: prefix, npm_config_prefix: prefix } } + const binary = npmExecutable(prefix) + const probe = async (file: string) => { + try { return { valid: true, version: await readFile(file.endsWith(".cmd") ? binary : file, "utf8") } } + catch { return { valid: false, missing: true } } + } + let installs = 0 + const service = new OpenCodeUpdateService({ + resolveBinary: () => ({ ...resolveDefaultInstallation(host), label: "fixture" }), + probeBinary: probe, + resolveLatestVersion: async () => "2.0.16", + canUpgradeBinary: () => Boolean(sharedInstallPrefix(host)), + upgradeBinary: async (_binary, version) => { + await installSharedOpenCode(version, { ...host, npm: "fixture-npm.js", probe, registerPath: async () => {}, + execute: async () => { + installs++ + await mkdir(path.dirname(binary), { recursive: true }) + await writeFile(binary, version) + await writeFile(path.join(path.dirname(binary), "../package.json"), JSON.stringify({ + name: "@opencode/cli", bin: { opencode2: "./bin/opencode.exe" }, + })) + }, + }) + return { success: true, version } + }, + }) + const before = await service.getStatus() + assert.equal(before.state, "missing") + assert.equal(before.canUpgrade, true, "the install button remains available after the failed npm cleanup") + await service.upgrade() + assert.equal(installs, 1) + assert.equal(sharedInstallPrefix(host), prefix) + assert.equal(resolveDefaultInstallation(host).source, "path") + assert.equal((await service.getStatus()).state, "ready") + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test("incomplete-installation recovery does not adopt custom wrappers or other prefixes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-incomplete-authority-")) + try { + const command = path.join(root, "opencode2.cmd") + const binary = npmExecutable(root, "win32") + await writeFile(command, shim) + assert.equal(isIncompleteWindowsNpmShim(command, root, binary), true) + assert.equal(isIncompleteWindowsNpmShim(command, path.join(root, "other"), binary), false) + for (const custom of [shim + 'echo custom\r\n', shim.replace('opencode.exe', 'custom.exe'), + '@echo off\r\n"%~dp0\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n']) { + await writeFile(command, custom) + assert.equal(isIncompleteWindowsNpmShim(command, root, binary), false) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/packages/server/src/opencode-update/incomplete-installation.ts b/packages/server/src/opencode-update/incomplete-installation.ts new file mode 100644 index 000000000..c9308a388 --- /dev/null +++ b/packages/server/src/opencode-update/incomplete-installation.ts @@ -0,0 +1,20 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" + +/** npm can remove a failed package but leave its terminal shims behind. Only + * recognize its exact native-executable shim in the chosen user prefix; never + * adopt a custom wrapper, another prefix, or an existing executable for repair. */ +export function isIncompleteWindowsNpmShim(command: string, prefix: string, binary: string): boolean { + if (path.resolve(path.dirname(command)) !== path.resolve(prefix) + || !/^opencode2?\.cmd$/i.test(path.basename(command)) || existsSync(binary)) return false + try { + const script = readFileSync(command, "utf8") + if (script.length > 4096) return false + const expected = [ + "@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0", + '"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*', + ] + const lines = script.trim().split(/\r?\n/).map(line => line.trim().replace(/\s+%\*$/, " %*")) + return lines.length === expected.length && lines.every((line, index) => line === expected[index]) + } catch { return false } +} diff --git a/packages/server/src/opencode-update/shared-installation.ts b/packages/server/src/opencode-update/shared-installation.ts index 69c530146..0a15e9b02 100644 --- a/packages/server/src/opencode-update/shared-installation.ts +++ b/packages/server/src/opencode-update/shared-installation.ts @@ -9,6 +9,7 @@ import { assertSupportedOpenCode } from "../opencode/runtime-support" import { compareVersionStrings } from "../releases/release-monitor" import { assertExecutableWritable, withInstallationLock } from "./installation-lock" import { upgradeSharedOpenCode } from "./native-upgrade" +import { isIncompleteWindowsNpmShim } from "./incomplete-installation" export interface InstallationHost { home?: string @@ -107,10 +108,14 @@ export function findPathOpenCode(host: InstallationHost = {}): string | undefine } export function resolveDefaultInstallation(host: InstallationHost = {}): { path: string; source?: "path" | "user" } { - const command = findPathOpenCode(host) - if (command) return { path: command, source: "path" } + const platform = host.platform ?? process.platform const prefix = userNpmPrefix(host) - const binary = npmCommand(prefix, host.platform ?? process.platform)?.binary ?? npmExecutable(prefix, host.platform) + const binary = npmCommand(prefix, platform)?.binary ?? npmExecutable(prefix, platform) + const command = findPathOpenCode(host) + if (command) { + if (platform === "win32" && isIncompleteWindowsNpmShim(command, prefix, binary)) return { path: binary, source: "user" } + return { path: command, source: "path" } + } if (existsSync(binary) && !isRetiredInstallation(binary)) return { path: binary, source: "user" } return { path: "opencode2" } } @@ -126,7 +131,8 @@ export function sharedInstallPrefix(host: InstallationHost = {}): string | undef if (!command) return userPrefix const prefix = platform === "win32" ? path.dirname(command) : path.dirname(path.dirname(command)) try { - if (!npmLauncher(command, prefix, platform)) return undefined + if (!npmLauncher(command, prefix, platform) + && !(platform === "win32" && isIncompleteWindowsNpmShim(command, userPrefix, npmExecutable(userPrefix, platform)))) return undefined accessSync(prefix, constants.W_OK) return prefix } catch { return undefined } diff --git a/packages/ui/src/stores/opencode-setup.ts b/packages/ui/src/stores/opencode-setup.ts index eb7ff97b2..dcd092837 100644 --- a/packages/ui/src/stores/opencode-setup.ts +++ b/packages/ui/src/stores/opencode-setup.ts @@ -83,8 +83,9 @@ export function refreshOpenCodeSetup(afterMutation = false, announce = false): P if (epoch !== generation) return setOpenCodeSetupStatus(status) setOpenCodeSetupCheckError(false) - setOpenCodeSetupError(false) - setOpenCodeInstallationError(undefined) + // A successful status read does not mean the preceding install/action + // succeeded. Keep its failure visible across refreshes and reopening; + // a new explicit action or executable selection clears it. if (!request.again && request.announce && !status.checkError && !status.serviceError && status.state !== "error") { setOpenCodeSetupFeedback("checked") } diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index a6e058c0d..378aa3efe 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -117,6 +117,13 @@ test("optional updates stay non-blocking and a failed install remains recoverabl assert.equal(starts, 0) assert.equal(attempts, 1) assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0) + await page.getByRole("button", { name: "Check status and updates" }).click() + await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]')) + assert.equal(await page.locator(".settings-error-message").count(), 1, "a successful status read must not erase the install failure") + await page.getByRole("button", { name: "Close", exact: true }).click() + await page.evaluate(() => (window as any).fixture.open()) + await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]')) + assert.equal(await page.locator(".settings-error-message").count(), 1, "reopening recovery preserves the failed action") await page.getByRole("button", { name: "Update to OpenCode 2.0.11" }).click() await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]')) assert.equal(attempts, 2)