From 22c23cb44cda463553d9b8cef7c02aa0a9ddc3c4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Jul 2026 20:13:08 -0400 Subject: [PATCH] fix(cli): reduce startup overhead --- packages/cli/script/postinstall.mjs | 161 ++++++++++++++++++ packages/cli/script/publish.ts | 15 +- packages/cli/src/commands/handlers/default.ts | 4 +- packages/cli/src/index.ts | 3 +- packages/cli/src/mini/runtime.boot.ts | 4 +- packages/cli/src/mini/variant.shared.ts | 3 +- packages/cli/src/server-process.ts | 3 +- packages/cli/src/services/standalone.ts | 4 +- 8 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 packages/cli/script/postinstall.mjs diff --git a/packages/cli/script/postinstall.mjs b/packages/cli/script/postinstall.mjs new file mode 100644 index 0000000000..c8619251cc --- /dev/null +++ b/packages/cli/script/postinstall.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +import childProcess from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" + +const directory = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) +const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8")) +const command = Object.keys(packageJson.bin ?? {})[0] +if (!command) throw new Error("OpenCode package does not declare a binary") + +const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform() +const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch() +const sourceBinary = platform === "windows" ? `${command}.exe` : command +const targetBinary = path.resolve(directory, packageJson.bin[command]) +const dependencies = packageJson.optionalDependencies ?? {} +const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`)) +if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`) + +function supportsAvx2() { + if (arch !== "x64") return false + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + return result.status === 0 && (result.stdout || "").trim() === "1" + } catch { + return false + } + } + if (platform === "windows") { + const script = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + return false +} + +function isMusl() { + if (platform !== "linux") return false + try { + if (fs.existsSync("/etc/alpine-release")) return true + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl") + } catch { + return false + } +} + +function packageNames() { + const baseline = arch === "x64" && !supportsAvx2() + const names = + platform === "linux" + ? isMusl() + ? arch === "x64" + ? baseline + ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + : [`${base}-musl`, base] + : arch === "x64" + ? baseline + ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + : [base, `${base}-musl`] + : arch === "x64" + ? baseline + ? [`${base}-baseline`, base] + : [base, `${base}-baseline`] + : [base] + return names.filter((name) => dependencies[name]) +} + +function copyBinary(source) { + if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`) + fs.mkdirSync(path.dirname(targetBinary), { recursive: true }) + if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary) + try { + fs.linkSync(source, targetBinary) + } catch { + fs.copyFileSync(source, targetBinary) + } + fs.chmodSync(targetBinary, 0o755) +} + +function resolveBinary(name) { + const packagePath = require.resolve(`${name}/package.json`) + return path.join(path.dirname(packagePath), "bin", sourceBinary) +} + +function installPackage(name) { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-")) + try { + const result = childProcess.spawnSync( + "npm", + ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`], + { stdio: "inherit", windowsHide: true }, + ) + if (result.status !== 0) return false + copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary)) + return true + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function verifyBinary() { + return ( + childProcess.spawnSync(targetBinary, ["--version"], { + stdio: "ignore", + windowsHide: true, + }).status === 0 + ) +} + +function main() { + const names = packageNames() + for (const name of names) { + try { + copyBinary(resolveBinary(name)) + if (verifyBinary()) return + } catch { + if (installPackage(name) && verifyBinary()) return + } + } + + throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`) +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index 55379b65ce..fd0f5cdb18 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -32,12 +32,23 @@ async function publishDistribution(input: { root: string; name: string; binary: if (!version) throw new Error(`No binary packages found for ${input.name}`) await $`mkdir -p ${input.root}/${input.name}/bin` - await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}` + await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs` + await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write( + [ + `echo "Error: ${input.name}'s postinstall script was not run." >&2`, + 'echo "" >&2', + 'echo "This occurs when installation scripts are disabled." >&2', + 'echo "Run the package postinstall script or reinstall with scripts enabled." >&2', + "exit 1", + "", + ].join("\n"), + ) await Bun.file(`${input.root}/${input.name}/package.json`).write( JSON.stringify( { name: input.name, - bin: { [input.binary]: `./bin/${input.binary}` }, + bin: { [input.binary]: `./bin/${input.binary}.exe` }, + scripts: { postinstall: "node ./postinstall.mjs" }, version, license: pkg.license, repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 1f1a441571..535003ed1a 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,4 +1,4 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { run } from "@opencode-ai/tui" import { Commands } from "../commands" @@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) => : Effect.logInfo(message, tags) runFork(effect) }, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) + }).pipe(Effect.provide(LayerNode.compile(Global.node))) }), ) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 04f3b5aa50..7ee80846ed 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,6 @@ import { Runtime } from "./framework/runtime" import { Observability } from "@opencode-ai/core/observability" import { Updater } from "./services/updater" import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { AppProcess } from "@opencode-ai/core/process" @@ -61,7 +60,7 @@ Effect.logInfo("cli starting", { Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), + Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), Effect.provide(Observability.layer), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/cli/src/mini/runtime.boot.ts b/packages/cli/src/mini/runtime.boot.ts index 5739bc7e9a..815f1a2080 100644 --- a/packages/cli/src/mini/runtime.boot.ts +++ b/packages/cli/src/mini/runtime.boot.ts @@ -7,8 +7,8 @@ // none block each other. import { Context, Effect, Layer } from "effect" import { resolve } from "@opencode-ai/tui/config/v1" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { loadRunProviders } from "./catalog.shared" import { resolveCurrentSession, sessionHistory } from "./session.shared" @@ -130,7 +130,7 @@ const layer = Layer.effect( ) const node = makeGlobalNode({ service: Service, layer, deps: [] }) -const runtime = makeRuntime(Service, AppNodeBuilder.build(node)) +const runtime = makeRuntime(Service, LayerNode.compile(node)) // Fetches available variants and context limits for every provider/model pair. export async function resolveModelInfo( diff --git a/packages/cli/src/mini/variant.shared.ts b/packages/cli/src/mini/variant.shared.ts index 196b4e040c..50adb1c979 100644 --- a/packages/cli/src/mini/variant.shared.ts +++ b/packages/cli/src/mini/variant.shared.ts @@ -8,7 +8,6 @@ // variant and the persisted file. import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -203,7 +202,7 @@ const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] }) /** @internal Exported for testing. */ export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime { - const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements)) + const runtime = makeRuntime(Service, LayerNode.compile(node, replacements)) return { resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}), diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 13cfaaed08..f3f8cbc61a 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -2,7 +2,6 @@ export * as ServerProcess from "./server-process" import { NodeServices } from "@effect/platform-node" import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -27,7 +26,7 @@ export type Options = { export const run = Effect.fnUntraced(function* (options: Options) { return yield* processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(NodeServices.layer), ) }) diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index d1ea7fc9c5..899af8153f 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -1,6 +1,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Schema, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { randomBytes } from "node:crypto" @@ -53,7 +53,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")( pid: proc.pid, } satisfies Endpoint & { readonly pid: number } }, - Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), + Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)), ) export function start(options: Options = {}) {