Compare commits

..
Author SHA1 Message Date
Kit Langton 662d68d6a4 feat(cli): make simulation development-only 2026-08-14 09:32:00 -04:00
15 changed files with 160 additions and 49 deletions
+2 -2
View File
@@ -711,12 +711,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -914,7 +914,6 @@
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -934,6 +933,7 @@
"uqr": "0.1.3",
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+33
View File
@@ -0,0 +1,33 @@
export const simulationExternals = ["@opencode-ai/simulation", "@opencode-ai/simulation/*"]
export const buildTargets: ReadonlyArray<{
readonly os: string
readonly arch: "arm64" | "x64"
readonly abi?: "musl"
readonly avx2?: false
}> = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
export function targetName(item: (typeof buildTargets)[number], binary = "opencode2") {
return [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
}
+6 -36
View File
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { buildTargets, simulationExternals, targetName } from "./build-config"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -27,36 +28,16 @@ const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
os: string
arch: "arm64" | "x64"
abi?: "musl"
avx2?: false
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
const targets =
requestedTarget !== undefined
? allTargets.filter((item) => targetName(item) === requestedTarget)
? buildTargets.filter((item) => targetName(item) === requestedTarget)
: singleFlag
? allTargets.filter((item) => {
? buildTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
: buildTargets
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
@@ -93,7 +74,7 @@ for (const item of targets) {
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
external: ["node-gyp", ...simulationExternals],
format: "esm",
minify: true,
sourcemap: "inline",
@@ -112,6 +93,7 @@ for (const item of targets) {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_SIMULATION: "false",
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
@@ -140,15 +122,3 @@ for (const item of targets) {
),
)
}
function targetName(item: (typeof allTargets)[number]) {
return [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
}
+22
View File
@@ -6,12 +6,14 @@ import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { verifyArtifact } from "./verify-artifact"
const nodeBuild = process.argv.includes("--node")
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
await verifyArtifact(binary)
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")))
const env = {
@@ -29,6 +31,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await verifySimulationDiagnostic()
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
@@ -104,6 +107,25 @@ if (failure)
cause: failure,
})
async function verifySimulationDiagnostic() {
const process = Bun.spawn([binary, "--version"], {
env: { ...env, OPENCODE_DRIVE: "artifact-smoke" },
stdout: "pipe",
stderr: "pipe",
})
const [exit, stdout, stderr] = await Promise.all([
process.exited,
new Response(process.stdout).text(),
new Response(process.stderr).text(),
])
if (exit === 0) throw new Error("Compiled CLI accepted a simulation request")
const output = stdout + stderr
if (!output.includes("Simulation is not included in production OpenCode builds"))
throw new Error(`Compiled CLI returned an opaque simulation error: ${output}`)
if (!output.includes("opencode-drive start --dev <checkout>"))
throw new Error(`Compiled CLI omitted the simulation migration command: ${output}`)
}
function spawnService() {
const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" })
processes.push(process)
+25
View File
@@ -0,0 +1,25 @@
import path from "node:path"
export async function verifyArtifact(binary: string) {
const contents = Buffer.from(await Bun.file(binary).arrayBuffer())
const target = path.basename(path.dirname(path.dirname(binary)))
const platform = target.includes("darwin") ? "darwin" : target.includes("linux") ? "linux" : "win32"
const markers = [
"SimulationPng",
"Drive.create",
"@napi-rs/canvas",
"commit-mono-latin-400-normal",
"noto-sans-symbols-symbols-400-normal",
"noto-sans-math-math-400-normal",
"../simulation/src/",
`skia.${platform}-`,
]
const found = markers.filter((marker) => contents.includes(marker))
if (found.length > 0) throw new Error(`Compiled CLI contains simulation artifacts: ${found.join(", ")}`)
}
if (import.meta.main) {
const binary = process.argv[2]
if (!binary) throw new Error("Usage: bun run script/verify-artifact.ts <binary>")
await verifyArtifact(binary)
}
+6
View File
@@ -12,6 +12,12 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { SimulationBuild } from "./simulation"
if (!SimulationBuild.enabled && (process.env.OPENCODE_DRIVE || process.env.OPENCODE_SIMULATE)) {
console.error(SimulationBuild.unavailable)
process.exit(1)
}
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
+6
View File
@@ -0,0 +1,6 @@
declare const OPENCODE_SIMULATION: boolean
export const SimulationBuild = {
enabled: typeof OPENCODE_SIMULATION === "boolean" ? OPENCODE_SIMULATION : true,
unavailable: "Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { buildTargets, simulationExternals, targetName } from "../script/build-config"
describe("published Bun build configuration", () => {
test("externalizes the simulation package and every subpath", () => {
expect(simulationExternals).toEqual(["@opencode-ai/simulation", "@opencode-ai/simulation/*"])
})
test("covers every configured target with the same production build", () => {
expect(buildTargets.map((target) => targetName(target))).toEqual([
"opencode2-linux-arm64",
"opencode2-linux-x64",
"opencode2-linux-x64-baseline",
"opencode2-linux-arm64-musl",
"opencode2-linux-x64-musl",
"opencode2-linux-x64-baseline-musl",
"opencode2-darwin-arm64",
"opencode2-darwin-x64",
"opencode2-darwin-x64-baseline",
"opencode2-windows-arm64",
"opencode2-windows-x64",
"opencode2-windows-x64-baseline",
])
})
})
+1
View File
@@ -253,6 +253,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_SIMULATION: "false",
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
},
+4
View File
@@ -168,6 +168,10 @@ Use `opencode-drive check ./drive.ts` and `start --script` for the Effect-native
## OpenCode development
Simulation is available only from an OpenCode development checkout after its
normal workspace install. Published `opencode2` and `opencode2-node` binaries do
not include the simulation runtime.
Run this:
```sh
+10 -7
View File
@@ -100,7 +100,10 @@ const startCommand = Command.make(
record: Flag.boolean("record").pipe(
Flag.withDescription("Record the complete headless session and export it on stop"),
),
dev: Flag.string("dev").pipe(Flag.optional, Flag.withDescription("Path to an OpenCode development checkout")),
dev: Flag.string("dev").pipe(
Flag.optional,
Flag.withDescription("Path to the required OpenCode development checkout"),
),
},
(config) =>
executeEffect(
@@ -113,16 +116,16 @@ const startCommand = Command.make(
Command.withDescription("Launch a local simulated OpenCode instance"),
Command.withExamples([
{
command: "opencode-drive start --name demo",
description: "Launch headless OpenCode on the default ports",
command: "opencode-drive start --name demo --dev <checkout>",
description: "Launch a headless development checkout on the default ports",
},
{
command: "opencode-drive start --visible",
description: "Launch visible OpenCode on the default ports",
command: "opencode-drive start --visible --dev <checkout>",
description: "Launch a visible development checkout on the default ports",
},
{
command: "opencode-drive start --name demo --script ./drive.ts",
description: "Launch headless OpenCode and run a script",
command: "opencode-drive start --name demo --script ./drive.ts --dev <checkout>",
description: "Launch a headless development checkout and run a script",
},
]),
)
+1 -1
View File
@@ -18,12 +18,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+8
View File
@@ -45,6 +45,8 @@ import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
declare const OPENCODE_SIMULATION: boolean
const applicationServiceNodes = [
Global.node,
Database.node,
@@ -134,6 +136,12 @@ function makeRoutes<AuthError, AuthServices>(
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
if (typeof OPENCODE_SIMULATION === "boolean" && !OPENCODE_SIMULATION)
return yield* Effect.fail(
new Error(
"Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
),
)
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(embedded ? embeddedApplicationServices : applicationServices, [
+1 -1
View File
@@ -85,7 +85,6 @@
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/theme": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -105,6 +104,7 @@
"uqr": "0.1.3"
},
"devDependencies": {
"@opencode-ai/simulation": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+10 -2
View File
@@ -101,6 +101,8 @@ import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
declare const OPENCODE_SIMULATION: boolean
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
@@ -245,8 +247,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
)
}
if (process.env.OPENCODE_DRIVE) {
const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
return yield* Drive.create(options, input.app.version)
if (typeof OPENCODE_SIMULATION === "boolean" && !OPENCODE_SIMULATION)
return yield* Effect.fail(
new Error(
"Simulation is not included in production OpenCode builds. Use opencode-drive start --dev <checkout>.",
),
)
const { create } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
return yield* create(options, input.app.version)
}
return yield* Effect.acquireRelease(
Effect.tryPromise({