-
Notifications
You must be signed in to change notification settings - Fork 0
β.2: vendor + pin opencode (closes #2) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3026004
562111f
3e4b95a
3c0da8a
b5d5ce4
f936ce1
9991d04
bc0f170
7369449
bae0df8
f8c8cde
be3a478
b2e604f
300b1f1
5de7bca
968fe03
26d9018
98ed8b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ dist/ | |
| *.vsix | ||
| .vscode-test/ | ||
| *.log | ||
| packages/extension/vendor/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # This file's EXISTENCE stops vsce falling back to .gitignore (which excludes vendor/). | ||
| # The explicit negation keeps the vendored opencode shipping even if a future | ||
| # exclusion pattern would otherwise swallow it — that's the trap β.2 exists to kill. | ||
| !vendor/** | ||
| src/** | ||
| test/** | ||
| scripts/** | ||
| node_modules/** | ||
| esbuild.config.mjs | ||
| tsconfig.json | ||
| **/*.map |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "version": "1.17.3", | ||
| "platforms": { | ||
| "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "b49948f96d8e92c577d54854e2f038389d03c3dfbbeacc44643b73123210fd13" }, | ||
| "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "d4bd238a2c1ff56aca1cd3397d21a0a317f5992234517a7f8e2afbbd72010a7d" } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| #!/usr/bin/env node | ||
| // Download-at-build vendoring of the opencode chat-server binary, pinned by | ||
| // opencode.lock.json (spec §2/§3). Importable module + CLI in one file. | ||
| import { createHash } from 'node:crypto' | ||
| import { execFileSync } from 'node:child_process' | ||
| import { | ||
| chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, | ||
| renameSync, rmSync, writeFileSync, | ||
| } from 'node:fs' | ||
| import { dirname, join } from 'node:path' | ||
| import { fileURLToPath, pathToFileURL } from 'node:url' | ||
|
|
||
| const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') | ||
|
|
||
| export function loadManifest(root = PKG_ROOT) { | ||
| const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8')) | ||
| if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string') | ||
| const platforms = m.platforms ?? {} | ||
| if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing') | ||
| for (const [key, p] of Object.entries(platforms)) { | ||
| if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`) | ||
| if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`) | ||
| } | ||
| return m | ||
| } | ||
|
|
||
| export function resolvePlatform(manifest, flag) { | ||
| const key = flag ?? `${process.platform}-${process.arch}` | ||
| if (!(key in manifest.platforms)) { | ||
| throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`) | ||
| } | ||
| return key | ||
| } | ||
|
|
||
| export function assetUrl(manifest, platform) { | ||
| return `https://github.com/sst/opencode/releases/download/v${manifest.version}/${manifest.platforms[platform].asset}` | ||
| } | ||
|
|
||
| export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') | ||
|
|
||
| async function defaultDownload(url) { | ||
| let r | ||
| try { r = await fetch(url) } catch (e) { | ||
| throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too | ||
| } | ||
| if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) | ||
| return Buffer.from(await r.arrayBuffer()) | ||
| } | ||
|
|
||
| export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { | ||
| const manifest = loadManifest(root) | ||
| const key = resolvePlatform(manifest, platform) | ||
| const { asset, sha256: want } = manifest.platforms[key] | ||
| const destDir = join(root, 'vendor', 'opencode', key) | ||
| const bin = join(destDir, 'opencode') | ||
| const stamp = join(destDir, '.sha256') | ||
|
|
||
| if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { | ||
| return { skipped: true, path: bin } // offline repeat builds | ||
| } | ||
|
|
||
| const bytes = await download(assetUrl(manifest, key)) | ||
| const got = sha256(bytes) | ||
| if (got !== want) { | ||
| // Possible supply-chain signal: no retry, no override (spec §3 step 4). | ||
| throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`) | ||
| } | ||
|
|
||
| mkdirSync(destDir, { recursive: true }) | ||
| const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic | ||
| try { | ||
| const archive = join(work, asset) | ||
| writeFileSync(archive, bytes) | ||
| if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work]) | ||
| else execFileSync('tar', ['-xzf', archive, '-C', work]) | ||
| if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`) | ||
| renameSync(join(work, 'opencode'), bin) | ||
| chmodSync(bin, 0o755) | ||
| writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5) | ||
| } finally { | ||
| rmSync(work, { recursive: true, force: true }) | ||
| } | ||
| return { skipped: false, path: bin } | ||
| } | ||
|
|
||
| async function main(argv) { | ||
| const flagIdx = argv.indexOf('--platform') | ||
| const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined | ||
| if (argv.includes('--record')) { // pin-time only (spec §3 step 6) | ||
| const manifest = loadManifest() | ||
| for (const key of Object.keys(manifest.platforms)) { | ||
| const bytes = await defaultDownload(assetUrl(manifest, key)) | ||
| console.log(`${key} ${sha256(bytes)}`) | ||
| } | ||
| return 0 | ||
| } | ||
| const r = await fetchOpencode({ platform }) | ||
| console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`) | ||
| return 0 | ||
| } | ||
|
|
||
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import * as vscode from "vscode"; | |
| import * as path from "node:path"; | ||
| import * as fs from "node:fs"; | ||
| import { ServerManager } from "./server_manager"; | ||
| import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; | ||
| import { ChatPanel } from "./chat_panel"; | ||
| import { registerRunInspector } from "./run_inspector"; | ||
| import { registerTrees } from "./trees"; | ||
|
|
@@ -53,39 +54,60 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> { | |
| opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); | ||
| opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); | ||
|
|
||
| // 4. Spawn opencode. PATH=binDir:$PATH so `amico-run` resolves; env vars | ||
| // also tell amico-run where to find julia+script in case the user has | ||
| // multiple checkouts. | ||
| const binary = vscode.workspace.getConfiguration("amicode").get<string>("opencodeBinary", "opencode"); | ||
| const juliaScript = resolveJuliaScript(ctx); | ||
| const juliaProject = resolveJuliaProject(); | ||
| serverManager = new ServerManager({ | ||
| binary, | ||
| cwd: opencodeProject.projectDir, | ||
| env: { | ||
| PATH: `${binDir}:${process.env.PATH ?? ""}`, | ||
| AMICO_JULIA_SCRIPT: juliaScript, | ||
| AMICO_JULIA_PROJECT: juliaProject, | ||
| AMICO_RUNS_ROOT: runsRoot, | ||
| }, | ||
| channel: opencodeChannel, | ||
| }); | ||
| ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); | ||
|
|
||
| // SSE event channel — opens once opencode is healthy. | ||
| sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); | ||
| ctx.subscriptions.push(sseClient); | ||
|
|
||
| serverManager.onReady((url) => { | ||
| opencodeReadyUrl = url; | ||
| statusBar?.setServerReady(true); | ||
| sseClient?.connect(url); | ||
| }); | ||
|
|
||
| serverManager.start().catch((err) => { | ||
| vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); | ||
| opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); | ||
| }); | ||
| // 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills | ||
| // Assumption 4). Config override is a dev-only escape hatch. On a missing | ||
| // binary, chat is disabled but the rest of the extension (inspector, | ||
| // watcher, commands) still activates. | ||
| let binary: string | undefined; | ||
| try { | ||
| const resolved = resolveOpencodeBinary( | ||
| ctx.extensionPath, | ||
| vscode.workspace.getConfiguration("amicode").get<string>("opencodeBinary", ""), | ||
| ); | ||
| binary = resolved.path; | ||
| opencodeChannel.appendLine( | ||
| resolved.source === "config-override" | ||
| ? `[boot] OVERRIDE: amicode.opencodeBinary = ${binary}` | ||
| : `[boot] vendored opencode: ${binary}`, | ||
| ); | ||
| } catch (e) { | ||
| if (e instanceof OpencodeMissingError) { | ||
| opencodeChannel.appendLine(`[boot] ${e.message} — chat disabled`); | ||
| void vscode.window.showErrorMessage(`Amicode: ${e.message}`); | ||
| } else throw e; | ||
| } | ||
|
|
||
| if (binary !== undefined) { | ||
| const juliaScript = resolveJuliaScript(ctx); | ||
| const juliaProject = resolveJuliaProject(); | ||
| serverManager = new ServerManager({ | ||
| binary, | ||
| cwd: opencodeProject.projectDir, | ||
| env: { | ||
| PATH: `${binDir}:${process.env.PATH ?? ""}`, | ||
| AMICO_JULIA_SCRIPT: juliaScript, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Rchari1 — this |
||
| AMICO_JULIA_PROJECT: juliaProject, | ||
| AMICO_RUNS_ROOT: runsRoot, | ||
| }, | ||
| channel: opencodeChannel, | ||
| }); | ||
| ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); | ||
|
|
||
| // SSE event channel — opens once opencode is healthy. | ||
| sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); | ||
| ctx.subscriptions.push(sseClient); | ||
|
|
||
| serverManager.onReady((url) => { | ||
| opencodeReadyUrl = url; | ||
| statusBar?.setServerReady(true); | ||
| sseClient?.connect(url); | ||
| }); | ||
|
|
||
| serverManager.start().catch((err) => { | ||
| vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); | ||
| opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); | ||
| }); | ||
| } | ||
|
|
||
| // 5. Commands | ||
| ctx.subscriptions.push( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { accessSync, constants } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| export class OpencodeMissingError extends Error {} | ||
|
|
||
| export interface ResolvedBinary { path: string; source: "config-override" | "vendored" } | ||
|
|
||
| const SUPPORTED = ["darwin-arm64", "linux-x64"] as const; | ||
|
|
||
| /** Spec §4: config override → vendored → hard error. NO $PATH fallback (Assumption 4 stays dead). */ | ||
| export function resolveOpencodeBinary(extensionRoot: string, configValue: string): ResolvedBinary { | ||
| const override = (configValue ?? "").trim(); | ||
| if (override !== "") return { path: override, source: "config-override" }; | ||
|
|
||
| const key = `${process.platform}-${process.arch}`; | ||
| if (!(SUPPORTED as readonly string[]).includes(key)) { | ||
| throw new OpencodeMissingError(`platform ${key} not supported (supported: ${SUPPORTED.join(", ")})`); | ||
| } | ||
| const vendored = join(extensionRoot, "vendor", "opencode", key, "opencode"); | ||
| try { | ||
| accessSync(vendored, constants.X_OK); | ||
| } catch { | ||
| throw new OpencodeMissingError( | ||
| `vendored opencode missing at ${vendored} — run \`pnpm --filter amicode-v2 fetch:opencode\` (dev) or reinstall the extension`, | ||
| ); | ||
| } | ||
| return { path: vendored, source: "vendored" }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| #!/usr/bin/env node | ||
| // β.2 boot-smoke gate (spec §5): the VENDORED opencode binary must serve | ||
| // GET /event as an SSE stream (HTTP 200, text/event-stream) against a | ||
| // synthesized project, with no LLM creds. Exit 0 = pass. | ||
| import { spawn } from 'node:child_process' | ||
| import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' | ||
| import { createServer } from 'node:net' | ||
| import { tmpdir } from 'node:os' | ||
| import { dirname, join } from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
|
|
||
| const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') | ||
| const key = `${process.platform}-${process.arch}` | ||
| const bin = join(PKG_ROOT, 'vendor', 'opencode', key, 'opencode') | ||
|
|
||
| const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) } | ||
|
|
||
| if (!existsSync(bin)) fail(`vendored binary missing at ${bin} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10) | ||
|
|
||
| // Synthesize a minimal opencode project (inline port of the spike's prepareOpencodeProject). | ||
| const proj = mkdtempSync(join(tmpdir(), 'amicode-smoke-')) | ||
| mkdirSync(join(proj, '.opencode'), { recursive: true }) | ||
| writeFileSync(join(proj, 'AGENTS.md'), '# Amicode boot smoke\n') | ||
| writeFileSync(join(proj, '.opencode', 'opencode.json'), | ||
| JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)) | ||
|
|
||
| const port = await new Promise((res, rej) => { | ||
| const srv = createServer() | ||
| srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => res(p)) }) | ||
| srv.on('error', rej) | ||
| }) | ||
|
|
||
| console.log(`[smoke] spawning ${bin} serve --port ${port}`) | ||
| let done = false | ||
| let serverLog = '' | ||
| const child = spawn(bin, ['serve', '--port', String(port)], { cwd: proj, stdio: ['ignore', 'pipe', 'pipe'] }) | ||
| child.on('error', e => fail(`spawn failed: ${e.message}`)) | ||
| child.stdout.on('data', d => { serverLog += d }) | ||
| child.stderr.on('data', d => { serverLog += d }) | ||
| child.on('exit', (c, s) => { if (!done) fail(`opencode exited early (code=${c} signal=${s})\n--- server output ---\n${serverLog}`) }) | ||
|
|
||
| // Poll until the server answers, 30s cap. | ||
| const deadline = Date.now() + 30_000 | ||
| let up = false | ||
| while (Date.now() < deadline && !up) { | ||
| try { | ||
| const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }) | ||
| if (r.status < 500) up = true | ||
| } catch { /* not up yet */ } | ||
| if (!up) await new Promise(r => setTimeout(r, 200)) | ||
| } | ||
| if (!up) { done = true; child.kill('SIGKILL'); fail(`server not up within 30s\n--- server output ---\n${serverLog}`) } | ||
|
|
||
| // THE gate: /event answers 200 with an SSE content-type. fetch resolves on | ||
| // headers (the SSE body streaming doesn't block us); 10s cap so a header-less | ||
| // hang fails fast instead of eating the CI job timeout. | ||
| const ctrl = new AbortController() | ||
| const gateTimer = setTimeout(() => ctrl.abort(), 10_000) | ||
| const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: ctrl.signal }) | ||
| clearTimeout(gateTimer) | ||
| const ctype = ev.headers.get('content-type') ?? '' | ||
| console.log(`[smoke] GET /event → ${ev.status} (${ctype})`) | ||
| if (ev.status !== 200) { done = true; child.kill('SIGKILL'); fail(`/event status ${ev.status}, want 200`) } | ||
| if (!ctype.includes('text/event-stream')) { done = true; child.kill('SIGKILL'); fail(`/event content-type "${ctype}", want text/event-stream`) } | ||
| ctrl.abort() | ||
|
|
||
| // Clean shutdown: SIGTERM, exit within 5s. | ||
| done = true | ||
| const exited = new Promise(res => child.on('exit', res)) | ||
| child.kill('SIGTERM') | ||
| const t = setTimeout(() => child.kill('SIGKILL'), 5_000) | ||
| await exited | ||
| clearTimeout(t) | ||
| console.log('[smoke] PASS') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both matrix legs re-download ~157MB on every run. Worth an
actions/cachestep keyed onhashFiles('packages/extension/opencode.lock.json'), restoringpackages/extension/vendor/before this step — the.sha256idempotency stamp already makesfetch:opencodea no-op on a cache hit, so unchanged pins skip the download entirely.