Skip to content

Commit fce42af

Browse files
fix: harden installation against 12 failure modes
Comprehensive fix for installation/upgrade reliability across all platforms. ## Fixes (12 issues) 1. **HTML guard on curl install** — altimate.ai/install currently returns HTML. Now detects HTML response and gives actionable error with npm workaround. 2. **Windows curl guard** — curl/bash upgrade on Windows now fails fast with PowerShell alternative command. 3. **Fetch timeout (10s)** — all fetch() calls now use AbortController timeout to prevent hanging on network issues. 4. **.altimate-code/bin path detection** — method() now detects curl installs in ~/.altimate-code/bin/ (not just ~/.opencode/bin/). 5. **Scoop upgrade command** — changed from `scoop install opencode@version` to `scoop update opencode` (correct command for upgrades). 6. **Choco error message** — removed hardcoded "not running from elevated shell", now shows actual stderr from choco process. 7. **Choco empty results guard** — data.d.results[0].Version no longer crashes when Chocolatey returns empty results array. 8. **Scoop missing version guard** — checks data.version exists before accessing. 9. **GitHub releases null guard** — checks data.tag_name exists before .replace(). 10. **Brew JSON parse safety** — JSON.parse wrapped in try/catch for non-JSON output. 11. **Brew core formula 404** — removed fetch to formulae.brew.sh/altimate-code.json (doesn't exist in homebrew core, only in AltimateAI/tap). Falls through to GitHub releases instead. 12. **Local channel skip** — npm/bun/pnpm version check returns current VERSION when CHANNEL is "local" instead of fetching /local from registry (404). ## Registry Status (verified) | Registry | Package | Exists? | |----------|---------|---------| | npm | @altimateai/altimate-code | ✅ v0.4.9 | | scoop | opencode | ✅ v1.2.27 (upstream) | | choco | opencode | ✅ v1.2.27 (upstream) | | brew | AltimateAI/tap/altimate-code | ✅ formula exists, v0.3.1 | | GitHub | AltimateAI/altimate-code | ✅ v0.4.9 with all platform binaries | ## Infra Issues (not code — documented for team) - altimate.ai/install returns HTML homepage (needs server config fix) - Brew tap formula pinned to v0.3.1 (needs tap update) - No altimate-code packages on scoop/choco (uses upstream opencode) ## Test Evidence Install matrix (55 combinations): https://github.com/AltimateAI/altimate-qa/actions/runs/23263489002
1 parent 0612f4e commit fce42af

2 files changed

Lines changed: 117 additions & 39 deletions

File tree

packages/opencode/src/cli/cmd/uninstall.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import os from "os"
99
import { Filesystem } from "../../util/filesystem"
1010
import { Process } from "../../util/process"
1111

12+
// altimate_change start — shell config markers (current + legacy for migration)
13+
const SHELL_MARKERS = ["# altimate-code", "# opencode"]
14+
const BIN_PATHS = [".altimate-code/bin", ".opencode/bin"]
15+
// altimate_change end
16+
1217
interface UninstallArgs {
1318
keepConfig: boolean
1419
keepData: boolean
@@ -180,13 +185,15 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
180185

181186
if (method !== "curl" && method !== "unknown") {
182187
const cmds: Record<string, string[]> = {
183-
npm: ["npm", "uninstall", "-g", "opencode-ai"],
184-
pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"],
185-
bun: ["bun", "remove", "-g", "opencode-ai"],
186-
yarn: ["yarn", "global", "remove", "opencode-ai"],
187-
brew: ["brew", "uninstall", "opencode"],
188+
// altimate_change start — correct package names
189+
npm: ["npm", "uninstall", "-g", "@altimateai/altimate-code"],
190+
pnpm: ["pnpm", "uninstall", "-g", "@altimateai/altimate-code"],
191+
bun: ["bun", "remove", "-g", "@altimateai/altimate-code"],
192+
yarn: ["yarn", "global", "remove", "@altimateai/altimate-code"],
193+
brew: ["brew", "uninstall", "altimate-code"],
188194
choco: ["choco", "uninstall", "opencode"],
189195
scoop: ["scoop", "uninstall", "opencode"],
196+
// altimate_change end
190197
}
191198

192199
const cmd = cmds[method]
@@ -215,7 +222,7 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
215222
prompts.log.info(` rm "${targets.binary}"`)
216223

217224
const binDir = path.dirname(targets.binary)
218-
if (binDir.includes(".opencode")) {
225+
if (BIN_PATHS.some((p) => binDir.includes(p.split("/")[0]))) {
219226
prompts.log.info(` rmdir "${binDir}" 2>/dev/null`)
220227
}
221228
}
@@ -266,7 +273,7 @@ async function getShellConfigFile(): Promise<string | null> {
266273
if (!exists) continue
267274

268275
const content = await Filesystem.readText(file).catch(() => "")
269-
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
276+
if (SHELL_MARKERS.some((m) => content.includes(m)) || BIN_PATHS.some((p) => content.includes(p))) {
270277
return file
271278
}
272279
}
@@ -284,22 +291,24 @@ async function cleanShellConfig(file: string) {
284291
for (const line of lines) {
285292
const trimmed = line.trim()
286293

287-
if (trimmed === "# opencode") {
294+
// altimate_change start — handle both current (altimate-code) and legacy (opencode) markers
295+
if (SHELL_MARKERS.includes(trimmed)) {
288296
skip = true
289297
continue
290298
}
291299

292300
if (skip) {
293301
skip = false
294-
if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) {
302+
if (BIN_PATHS.some((p) => trimmed.includes(p)) || trimmed.includes("fish_add_path")) {
295303
continue
296304
}
297305
}
298306

299307
if (
300-
(trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) ||
301-
(trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode"))
308+
(trimmed.startsWith("export PATH=") && BIN_PATHS.some((p) => trimmed.includes(p))) ||
309+
(trimmed.startsWith("fish_add_path") && BIN_PATHS.some((p) => trimmed.includes(p.split("/")[0])))
302310
) {
311+
// altimate_change end
303312
continue
304313
}
305314

packages/opencode/src/installation/index.ts

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ declare global {
2525
export namespace Installation {
2626
const log = Log.create({ service: "installation" })
2727

28+
// altimate_change start — fetch with timeout to prevent hanging
29+
const FETCH_TIMEOUT_MS = 10_000
30+
31+
async function fetchWithTimeout(url: string, opts?: RequestInit): Promise<Response> {
32+
const controller = new AbortController()
33+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
34+
try {
35+
return await fetch(url, { ...opts, signal: controller.signal })
36+
} finally {
37+
clearTimeout(timeout)
38+
}
39+
}
40+
// altimate_change end
41+
2842
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
2943
return Process.text(cmd, {
3044
cwd: opts.cwd,
@@ -34,10 +48,31 @@ export namespace Installation {
3448
}
3549

3650
async function upgradeCurl(target: string) {
37-
const body = await fetch("https://altimate.ai/install").then((res) => {
38-
if (!res.ok) throw new Error(res.statusText)
39-
return res.text()
40-
})
51+
// altimate_change start — validate install script before piping to bash
52+
const installUrl = "https://altimate.ai/install"
53+
const res = await fetchWithTimeout(installUrl)
54+
if (!res.ok) throw new Error(`Failed to fetch install script: ${res.status} ${res.statusText}`)
55+
const body = await res.text()
56+
57+
// Guard: ensure response is a shell script, not HTML
58+
if (body.trimStart().startsWith("<!") || body.trimStart().startsWith("<html")) {
59+
throw new Error(
60+
`Install URL (${installUrl}) returned HTML instead of a shell script. ` +
61+
`This is a server configuration issue. ` +
62+
`As a workaround, upgrade via npm: npm install -g @altimateai/altimate-code@${target}`,
63+
)
64+
}
65+
66+
// Guard: Windows users should use PowerShell install
67+
if (process.platform === "win32") {
68+
throw new Error(
69+
`curl install method is not supported on Windows. ` +
70+
`Use PowerShell: irm https://altimate.ai/install.ps1 | iex\n` +
71+
`Or npm: npm install -g @altimateai/altimate-code@${target}`,
72+
)
73+
}
74+
// altimate_change end
75+
4176
const proc = Process.spawn(["bash"], {
4277
stdin: "pipe",
4378
stdout: "pipe",
@@ -101,6 +136,9 @@ export namespace Installation {
101136

102137
export async function method() {
103138
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
139+
// altimate_change start — detect altimate-code curl installs
140+
if (process.execPath.includes(path.join(".altimate-code", "bin"))) return "curl"
141+
// altimate_change end
104142
if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
105143
const exec = process.execPath.toLowerCase()
106144

@@ -225,16 +263,17 @@ export namespace Installation {
225263
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
226264
break
227265
case "scoop":
228-
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
266+
// altimate_change start — scoop uses "update" not "install" for upgrades
267+
result = await Process.run(["scoop", "update", "opencode"], { nothrow: true })
268+
// altimate_change end
229269
break
230270
default:
231271
throw new Error(`Unknown method: ${method}`)
232272
}
233-
// altimate_change start — telemetry for upgrade result
273+
// altimate_change start — telemetry for upgrade result + actual error messages
234274
const telemetryMethod = (["npm", "bun", "brew"].includes(method) ? method : "other") as "npm" | "bun" | "brew" | "other"
235275
if (!result || result.code !== 0) {
236-
const stderr =
237-
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
276+
const stderr = result?.stderr.toString("utf8") || "upgrade failed (unknown error)"
238277
const T = await getTelemetry()
239278
T.track({
240279
type: "upgrade_attempted",
@@ -282,31 +321,39 @@ export namespace Installation {
282321
if (detectedMethod === "brew") {
283322
const formula = await getBrewFormula()
284323
if (formula.includes("/")) {
324+
// altimate_change start — safe JSON parse for brew info
285325
const infoJson = await text(["brew", "info", "--json=v2", formula])
286-
const info = JSON.parse(infoJson)
287-
const version = info.formulae?.[0]?.versions?.stable
288-
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
289-
return version
326+
try {
327+
const info = JSON.parse(infoJson)
328+
const version = info.formulae?.[0]?.versions?.stable
329+
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
330+
return version
331+
} catch (e: any) {
332+
throw new Error(`Failed to parse brew info for ${formula}: ${e.message}`)
333+
}
334+
// altimate_change end
290335
}
291-
// altimate_change start — brew formula URL
292-
return fetch("https://formulae.brew.sh/api/formula/altimate-code.json")
336+
// altimate_change start — brew formula URL (use tap info, not homebrew core which doesn't have it)
337+
// altimate-code is NOT in homebrew core — only in AltimateAI/tap
338+
// Fall through to GitHub releases instead of hitting formulae.brew.sh (which 404s)
293339
// altimate_change end
294-
.then((res) => {
295-
if (!res.ok) throw new Error(res.statusText)
296-
return res.json()
297-
})
298-
.then((data: any) => data.versions.stable)
299340
}
300341

301342
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
343+
// altimate_change start — skip registry check for local channel
344+
if (CHANNEL === "local") {
345+
log.info("skipping version check for local channel")
346+
return VERSION
347+
}
348+
// altimate_change end
302349
const registry = await iife(async () => {
303350
const r = (await text(["npm", "config", "get", "registry"])).trim()
304351
const reg = r || "https://registry.npmjs.org"
305352
return reg.endsWith("/") ? reg.slice(0, -1) : reg
306353
})
307354
const channel = CHANNEL
308355
// altimate_change start — npm package name for version check
309-
return fetch(`${registry}/@altimateai/altimate-code/${channel}`)
356+
return fetchWithTimeout(`${registry}/@altimateai/altimate-code/${channel}`)
310357
// altimate_change end
311358
.then((res) => {
312359
if (!res.ok) throw new Error(res.statusText)
@@ -316,33 +363,55 @@ export namespace Installation {
316363
}
317364

318365
if (detectedMethod === "choco") {
319-
return fetch(
366+
return fetchWithTimeout(
320367
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
321368
{ headers: { Accept: "application/json;odata=verbose" } },
322369
)
323370
.then((res) => {
324371
if (!res.ok) throw new Error(res.statusText)
325372
return res.json()
326373
})
327-
.then((data: any) => data.d.results[0].Version)
374+
.then((data: any) => {
375+
// altimate_change start — guard against empty results
376+
const results = data?.d?.results
377+
if (!results || !Array.isArray(results) || results.length === 0) {
378+
throw new Error("Chocolatey package 'opencode' not found or returned empty results")
379+
}
380+
return results[0].Version
381+
// altimate_change end
382+
})
328383
}
329384

330385
if (detectedMethod === "scoop") {
331-
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
386+
return fetchWithTimeout("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
332387
headers: { Accept: "application/json" },
333388
})
334389
.then((res) => {
335-
if (!res.ok) throw new Error(res.statusText)
390+
if (!res.ok) throw new Error(`Scoop manifest fetch failed: ${res.status}`)
336391
return res.json()
337392
})
338-
.then((data: any) => data.version)
393+
.then((data: any) => {
394+
// altimate_change start — guard against missing version field
395+
if (!data?.version) {
396+
throw new Error("Scoop manifest for 'opencode' missing version field")
397+
}
398+
return data.version
399+
// altimate_change end
400+
})
339401
}
340402

341-
return fetch("https://api.github.com/repos/AltimateAI/altimate-code/releases/latest")
403+
// altimate_change start — fallback to GitHub releases with safe access
404+
return fetchWithTimeout("https://api.github.com/repos/AltimateAI/altimate-code/releases/latest")
342405
.then((res) => {
343-
if (!res.ok) throw new Error(res.statusText)
406+
if (!res.ok) throw new Error(`GitHub releases API returned ${res.status}`)
344407
return res.json()
345408
})
346-
.then((data: any) => data.tag_name.replace(/^v/, ""))
409+
.then((data: any) => {
410+
if (!data?.tag_name) {
411+
throw new Error("No releases found for AltimateAI/altimate-code")
412+
}
413+
return data.tag_name.replace(/^v/, "")
414+
})
415+
// altimate_change end
347416
}
348417
}

0 commit comments

Comments
 (0)