Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@
"lint:skills": "node scripts/lint-skills.mjs --known scripts/lint-skills-known.txt",
"smoke:fleet": "node scripts/fleet_beta_smoke.mjs",
"fetch:opencode": "node scripts/fetch_opencode.mjs",
"build:binary": "node scripts/build_binary.mjs",
"opencode:dev": "node scripts/opencode_dev.mjs",
"opencode:build": "node scripts/opencode_dev.mjs build",
"opencode:pin": "node scripts/opencode_dev.mjs pin",
Expand Down
198 changes: 198 additions & 0 deletions packages/extension/scripts/build_binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#!/usr/bin/env node
// build_binary.mjs — compile the opencode engine binary from the materialized
// overlay tree (stock upstream + overlay applied on top). Phase 2a of
// fork-absorption (#1094): replaces the fork-release download path
// (`fetch:opencode --release`) with a local build.
//
// USAGE
// node scripts/build_binary.mjs # full recipe
// node scripts/build_binary.mjs --work <dir> # reuse an existing materialized tree
//
// Prerequisites: bun (https://bun.sh) — the build uses `bun build --compile`.
//
// The build produces a single-platform binary at
// vendor/opencode/<platform>/opencode
// with .sha256 and .source sidecars (matching the fetch_opencode.mjs contract).
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = join(EXT_ROOT, "..", "..");
const BUNDLE_PKG = join(REPO_ROOT, "packages", "app-bundle");

const args = process.argv.slice(2);
const flag = (n) => {
const i = args.indexOf(`--${n}`);
return i >= 0 ? args[i + 1] : undefined;
};

const fail = (msg, code = 1) => {
console.error(`[build:binary] FAIL: ${msg}`);
process.exit(code);
};

const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");

// ── bun resolution (same ladder as fetch_opencode.mjs) ──────────────────────
function resolveBun() {
if (process.env.AMICODE_BUN) return process.env.AMICODE_BUN;
const which = spawnSync("which", ["bun"], { encoding: "utf8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
const fallback = join(homedir(), ".bun", "bin", "bun");
if (existsSync(fallback)) return fallback;
fail(
"bun not found — the binary build requires bun (https://bun.sh).\n" +
" Install it: curl -fsSL https://bun.sh/install | bash\n" +
" Or set: AMICODE_BUN=/path/to/bun",
);
}

const run = (cmd, cmdArgs, cwd, note) => {
console.log(`[build:binary] ${note}: ${cmd} ${cmdArgs.join(" ")} (cwd=${cwd})`);
const r = spawnSync(cmd, cmdArgs, { cwd, stdio: "inherit" });
if (r.status !== 0) fail(`${note} failed (exit ${r.status})`, 2);
};

// ── platform key (matches fetch_opencode.mjs / opencode.lock.json keys) ─────
const platformKey = `${process.platform}-${process.arch}`;

// ── load the lock for OPENCODE_VERSION ──────────────────────────────────────
const lockPath = join(EXT_ROOT, "opencode.lock.json");
if (!existsSync(lockPath)) fail(`opencode.lock.json not found at ${lockPath}`);
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
if (!lock.version) fail("opencode.lock.json has no version field");
const version = lock.version;
console.log(`[build:binary] version from lock: ${version}`);
console.log(`[build:binary] platform: ${platformKey}`);

// ── load the app-bundle manifest for overlay provenance ─────────────────────
const manifestPath = join(BUNDLE_PKG, "manifest.json");
let manifestSha = "unknown";
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifestSha = manifest.fork_sha ?? "unknown";
} else {
console.warn("[build:binary] WARNING: no app-bundle manifest.json — provenance will say 'unknown'");
}

// ── materialized tree (reuse build:app's tree or create one) ────────────────
const work = flag("work") ?? process.env.AMICODE_APP_BUNDLE_WORK ?? join(BUNDLE_PKG, ".materialized");

// Overlay staleness check — same mechanism as build_app_bundle.mjs
const OVERLAY_STAMP = join(work, ".overlay-stamp");
const overlayVersion = (() => {
try {
const m = JSON.parse(readFileSync(manifestPath, "utf8"));
return `${m.fork_sha ?? ""}:${m.promoted_at ?? ""}`;
} catch { return null; }
})();
const cachedVersion = (() => {
try { return readFileSync(OVERLAY_STAMP, "utf8").trim(); }
catch { return null; }
})();
const cacheExists = existsSync(join(work, "package.json"));
const cacheStale = cacheExists && overlayVersion && cachedVersion !== overlayVersion;

if (cacheStale) {
console.log("[build:binary] overlay changed (manifest fork_sha differs from cached stamp) — clearing stale .materialized");
console.log(`[build:binary] cached: ${cachedVersion ?? "(none)"}`);
console.log(`[build:binary] current: ${overlayVersion}`);
rmSync(work, { recursive: true, force: true });
}

if (!existsSync(join(work, "package.json"))) {
run(
process.execPath,
[join(BUNDLE_PKG, "scripts", "materialize.mjs"), "--out", work],
REPO_ROOT,
"materialize (canonical base + overlay)",
);
// Stamp so subsequent builds can detect staleness
if (overlayVersion) writeFileSync(OVERLAY_STAMP, overlayVersion + "\n");
} else {
console.log(`[build:binary] reusing materialized tree at ${work}`);
// Backfill stamp for trees materialized before this check existed
if (!cachedVersion && overlayVersion) {
writeFileSync(OVERLAY_STAMP, overlayVersion + "\n");
console.log("[build:binary] backfilled .overlay-stamp for existing .materialized tree");
}
}

const opencodePkg = join(work, "packages", "opencode");
if (!existsSync(opencodePkg))
fail(`${work} has no packages/opencode — not a materialized tree`);

// ── bun install in the materialized tree ────────────────────────────────────
const bun = resolveBun();
console.log(`[build:binary] bun: ${bun}`);
run(bun, ["install"], work, "bun install (materialized tree deps)");

// ── build the binary ────────────────────────────────────────────────────────
// The fork's build recipe: Script reads OPENCODE_VERSION and OPENCODE_CHANNEL
// from env. OPENCODE_RELEASE must NOT be set (it triggers release upload).
// --single: build only the current platform
// --skip-install: deps already installed above
// --skip-embed-web-ui: the shelf serves the app separately
const buildEnv = {
...process.env,
OPENCODE_VERSION: version,
OPENCODE_CHANNEL: "dev",
PATH: `${dirname(bun)}${delimiter}${process.env.PATH ?? ""}`,
};
// Defensive: ensure OPENCODE_RELEASE is NOT set
delete buildEnv.OPENCODE_RELEASE;

console.log(`[build:binary] building with OPENCODE_CHANNEL=dev OPENCODE_VERSION=${version}`);
const buildResult = spawnSync(
bun,
["run", "script/build.ts", "--single", "--skip-install", "--skip-embed-web-ui"],
{ cwd: opencodePkg, env: buildEnv, stdio: "inherit" },
);
if (buildResult.status !== 0) fail(`binary build failed (exit ${buildResult.status})`);

// ── locate the artifact ─────────────────────────────────────────────────────
const artifactName = `opencode-${platformKey}`;
const artifact = join(opencodePkg, "dist", artifactName, "bin", "opencode");
if (!existsSync(artifact))
fail(`built binary not found at ${artifact} — build produced no artifact for ${platformKey}`);

console.log(`[build:binary] built artifact: ${artifact}`);

// ── install into vendor/ ────────────────────────────────────────────────────
const destDir = join(EXT_ROOT, "vendor", "opencode", platformKey);
const destBin = join(destDir, "opencode");
mkdirSync(destDir, { recursive: true });

const bytes = readFileSync(artifact);
const hash = sha256(bytes);
const provenance = `overlay ${manifestSha}`;

writeFileSync(destBin, bytes);
chmodSync(destBin, 0o755);
writeFileSync(join(destDir, ".sha256"), hash + "\n");
writeFileSync(join(destDir, ".source"), provenance + "\n");

console.log(`[build:binary] installed: ${destBin}`);
console.log(`[build:binary] sha256: ${hash}`);
console.log(`[build:binary] source: ${provenance}`);

// ── smoke test: version check ───────────────────────────────────────────────
const ver = spawnSync(destBin, ["--version"], { encoding: "utf8", timeout: 10000 });
if (ver.status !== 0) {
console.warn(`[build:binary] WARNING: version check failed (exit ${ver.status}): ${ver.stderr?.trim()}`);
} else {
console.log(`[build:binary] version: ${ver.stdout.trim()}`);
}

console.log("[build:binary] DONE");
Loading