fix(setup): recover interrupted Windows OpenCode installations (#783)

## Summary
Fix the first-install recovery dead end observed in CodeNomad 0.20.0 on
Windows 11. npm failed to install the optional Windows binary packages,
then removed the CLI package while leaving terminal shims behind. These
shims disabled installation admission and hid the install button; later
status refreshes also erased the action error.

Recognize only the exact npm-generated orphan shim in the selected user
prefix with a missing canonical executable. Preserve custom-wrapper,
foreign-prefix and existing-binary authority checks. Keep action
failures visible across refresh and reopening.

## Validation
- 34 targeted updater/installer regressions passed.
- 21 browser setup regressions passed.
- Server TypeScript check passed with locked dependencies.
- On the affected machine, isolated package download and executable
validation succeeded without Git. Explicitly authorized repair using
bundled npm succeeded; the user confirmed CodeNomad now opens folders.

## Distribution
The maintainer explicitly requested replacing the existing 0.20.0
desktop release assets rather than creating 0.20.1. This branch is based
on v0.20.0 and preserves its version; the PR build artifacts will be
used for that revision after validation. The release notes will record
the source commit, and WinGet will receive a separate same-version
installer hash correction. No npm version republication is intended.

Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
Pascal André 2026-09-25 23:52:35 +02:00 • committed by GitHub
parent dd7d1ee957
commit 999ca9751d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 183 additions and 6 deletions

View file

@ -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.

View file

@ -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 }) }
})

View file

@ -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 }
}

View file

@ -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 }

View file

@ -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")
}

View file

@ -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)