diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index ca3b84bb06..ad71d93854 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -5,6 +5,7 @@ import { rm } from "fs/promises" import path from "path" import { Script } from "@opencode-ai/script" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" +import type { BunPlugin } from "bun" import pkg from "../package.json" import { modelsData } from "./generate" @@ -22,7 +23,7 @@ await rm(outdir, { recursive: true, force: true }) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") -const plugin = createSolidTransformPlugin() +const solidPlugin = createSolidTransformPlugin() const allTargets: { os: string @@ -55,6 +56,16 @@ const targets = singleFlag if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` for (const item of targets) { + const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}` + const parcelWatcherPlugin: BunPlugin = { + name: "parcel-watcher-binding", + setup(build) { + build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({ + contents: `import binding from ${JSON.stringify(parcelWatcherPackage)}; export default () => binding`, + loader: "js", + })) + }, + } const target = [ binary, item.os === "win32" ? "windows" : item.os, @@ -69,7 +80,7 @@ for (const item of targets) { const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [plugin], + plugins: [solidPlugin, parcelWatcherPlugin], external: ["node-gyp"], format: "esm", minify: true, diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index 2a51fc22ff..346512cd87 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -13,7 +13,7 @@ const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["nod 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}`) -const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")) +const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))) const env = { ...process.env, HOME: root, @@ -29,6 +29,7 @@ const processes: Array> = [] const errors: Array> = [] let failure: unknown try { + await fs.mkdir(path.join(root, ".opencode")) spawnService() spawnService() const registration = await waitForRegistration() @@ -49,6 +50,11 @@ try { { signal: AbortSignal.timeout(5_000) }, ) if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication") + if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation") + const plugin = path.join(root, ".opencode", "plugins", "smoke.ts") + await fs.mkdir(path.dirname(plugin), { recursive: true }) + await fs.writeFile(plugin, pluginSource()) + await waitForPlugin(info.url, headers) const unauthorizedHealth = await fetch(new URL("/api/health", info.url), { signal: AbortSignal.timeout(5_000), @@ -88,6 +94,7 @@ try { } finally { processes.forEach((process) => process.kill()) await Promise.all(processes.map((process) => process.exited)) + if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => "")) } const output = await Promise.all(errors) @@ -133,3 +140,31 @@ async function waitForReady(url: string, headers: HeadersInit) { function exitsWithin(process: Bun.Subprocess, milliseconds: number) { return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)]) } + +function pluginSource() { + return 'export default { id: "smoke", setup: async () => {} }\n' +} + +async function pluginIDs(url: string, headers: HeadersInit) { + const endpoint = new URL("/api/plugin", url) + endpoint.searchParams.set("location[directory]", root) + const response = await fetch(endpoint, { headers, signal: AbortSignal.timeout(5_000) }) + const body: unknown = await response.json() + if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) { + throw new Error("Compiled service returned an invalid plugin list") + } + return body.data.flatMap((plugin) => + typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string" + ? [plugin.id] + : [], + ) +} + +async function waitForPlugin(url: string, headers: HeadersInit) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if ((await pluginIDs(url, headers)).includes("smoke")) return + await Bun.sleep(25) + } + throw new Error("Compiled service did not discover the created plugin") +} diff --git a/packages/core/src/filesystem/watcher-binding.ts b/packages/core/src/filesystem/watcher-binding.ts new file mode 100644 index 0000000000..86a848878b --- /dev/null +++ b/packages/core/src/filesystem/watcher-binding.ts @@ -0,0 +1,13 @@ +import { createRequire } from "node:module" + +declare const OPENCODE_LIBC: string | undefined + +const require = createRequire(import.meta.url) + +export default function load() { + const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC + return require( + process.env.OPENCODE_PARCEL_WATCHER_PATH ?? + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, + ) +} diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 11f8ac7f69..f7fef53029 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -9,23 +9,14 @@ import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "ef import { lazy } from "../util/lazy" import { watch as watchFileSystem } from "node:fs" import path from "path" -import { createRequire } from "node:module" - -declare const OPENCODE_LIBC: string | undefined +import loadBinding from "./watcher-binding" const SUBSCRIBE_TIMEOUT_MS = 10_000 -const require = createRequire(import.meta.url) - export const Event = { Updated: FileSystem.Event.Changed } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { - const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC - const binding = require( - process.env.OPENCODE_PARCEL_WATCHER_PATH ?? - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, - ) - return createWrapper(binding) as typeof import("@parcel/watcher") + return createWrapper(loadBinding()) as typeof import("@parcel/watcher") } catch { return }