mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-19 15:34:12 +00:00
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:
parent
dd7d1ee957
commit
999ca9751d
6 changed files with 183 additions and 6 deletions
53
dev-docs/OPENCODE_FIRST_INSTALL_RECOVERY.md
Normal file
53
dev-docs/OPENCODE_FIRST_INSTALL_RECOVERY.md
Normal 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.
|
||||
|
|
@ -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 }) }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue