From 850721c3e9829a51c7e7a66670a8256737db4418 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:35:56 +1000 Subject: [PATCH] feat(desktop): compose V2 sidecar --- .github/workflows/publish.yml | 2 + bun.lock | 63 ++++++++------- packages/desktop/.gitignore | 1 + .../desktop/electron-builder.config.test.ts | 17 ++++ packages/desktop/electron-builder.config.ts | 6 +- packages/desktop/electron.vite.config.test.ts | 12 +++ packages/desktop/electron.vite.config.ts | 41 +++------- packages/desktop/package.json | 35 ++++---- packages/desktop/scripts/prebuild.ts | 2 +- packages/desktop/scripts/predev.ts | 2 +- packages/desktop/scripts/prepare.ts | 2 - .../desktop/scripts/stage-server-assets.ts | 36 +++++++++ packages/desktop/scripts/target.ts | 72 +++++++++++++++++ packages/desktop/scripts/utils.test.ts | 28 +++++++ packages/desktop/scripts/utils.ts | 52 +----------- packages/desktop/src/main/env.d.ts | 12 --- packages/desktop/src/main/server.ts | 36 ++++++--- packages/desktop/src/main/sidecar.ts | 29 +++++-- packages/desktop/src/main/wsl/runtime.ts | 14 +++- packages/sdk-next/package.json | 12 ++- .../sdk-next/src/plugin-runtime.effect.ts | 28 +++++++ .../sdk-next/src/plugin-runtime.promise.ts | 26 ++++++ packages/sdk-next/src/plugin-runtime.ts | 61 ++++++++++++++ packages/sdk-next/src/server-interface.ts | 16 ++++ packages/sdk-next/src/server.ts | 79 +++++++++++++++++++ packages/sdk-next/src/vite.ts | 43 ++++++++++ .../sdk-next/test/import-boundaries.test.ts | 13 ++- turbo.json | 4 + 28 files changed, 580 insertions(+), 164 deletions(-) create mode 100644 packages/desktop/electron.vite.config.test.ts create mode 100644 packages/desktop/scripts/stage-server-assets.ts create mode 100644 packages/desktop/scripts/target.ts create mode 100644 packages/desktop/scripts/utils.test.ts create mode 100644 packages/sdk-next/src/plugin-runtime.effect.ts create mode 100644 packages/sdk-next/src/plugin-runtime.promise.ts create mode 100644 packages/sdk-next/src/plugin-runtime.ts create mode 100644 packages/sdk-next/src/server-interface.ts create mode 100644 packages/sdk-next/src/server.ts create mode 100644 packages/sdk-next/src/vite.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 180c6b77c0..c70a06a12e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -308,6 +308,7 @@ jobs: - host: "windows-2025" target: aarch64-pc-windows-msvc platform_flag: --win --arm64 + bun_install_flags: --os=win32 --cpu=arm64 - host: "blacksmith-4vcpu-windows-2025" target: x86_64-pc-windows-msvc platform_flag: --win @@ -388,6 +389,7 @@ jobs: working-directory: packages/desktop env: NODE_OPTIONS: --max-old-space-size=4096 + RUST_TARGET: ${{ matrix.settings.target }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/bun.lock b/bun.lock index a8ef37689e..b6c6cb563a 100644 --- a/bun.lock +++ b/bun.lock @@ -174,12 +174,14 @@ "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", + "ws": "8.21.0", }, "devDependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/httpapi-codegen": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/ws": "8.18.1", "@typescript/native-preview": "catalog:", "effect": "catalog:", }, @@ -431,10 +433,28 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@lydell/node-pty": "catalog:", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@opencode-ai/app": "workspace:*", + "@opencode-ai/client": "workspace:*", + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/sdk-next": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -452,22 +472,6 @@ "vite": "catalog:", "zod-openapi": "5.4.6", }, - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", - "@lydell/node-pty-linux-x64": "1.2.0-beta.12", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", - "@lydell/node-pty-win32-x64": "1.2.0-beta.12", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1", - }, }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", @@ -650,6 +654,7 @@ "packages/sdk-next": { "name": "@opencode-ai/sdk-next", "dependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -663,6 +668,8 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "vite": "catalog:", + "ws": "8.21.0", }, }, "packages/server": { @@ -1039,23 +1046,23 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", - "electron", "web-tree-sitter", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { - "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index 6923045cd9..71dd967601 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -25,5 +25,6 @@ dist-ssr out/ resources/opencode-cli* +resources/server-assets resources/icons resources/*.metainfo.xml diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index eee2b825cb..238d126562 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -46,3 +46,20 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") expect(desktop).toContain("NoDisplay=true") }) + +test("packages server native assets outside ASAR", async () => { + const module = await import("./electron-builder.config") + const config = module.default + const resources = Array.isArray(config.extraResources) ? config.extraResources : [] + + expect(config.files).toContain("!resources/server-assets/**/*") + expect(resources).toContainEqual({ from: "resources/server-assets/", to: "server-assets/" }) +}) + +test("keeps target native variants out of packaged production dependencies", async () => { + const pkg = await Bun.file("package.json").json() + + expect(pkg.optionalDependencies).toBeUndefined() + expect(pkg.devDependencies["@lydell/node-pty-win32-arm64"]).toBe("1.2.0-beta.12") + expect(pkg.devDependencies["@parcel/watcher-win32-arm64"]).toBe("2.5.1") +}) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 23afe811c2..814f863fcb 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -52,13 +52,17 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*"], + files: ["out/**/*", "resources/**/*", "!resources/server-assets/**/*"], extraResources: [ { from: "native/", to: "native/", filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"], }, + { + from: "resources/server-assets/", + to: "server-assets/", + }, ], mac: { category: "public.app-category.developer-tools", diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts new file mode 100644 index 0000000000..a458078038 --- /dev/null +++ b/packages/desktop/electron.vite.config.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import config from "./electron.vite.config" + +test("bundles the Node client into the packaged main process", () => { + expect(config.main?.build?.externalizeDeps).toEqual({ exclude: ["@opencode-ai/client"] }) +}) + +test("keeps the bundled Node client out of packaged production dependencies", async () => { + const pkg = await Bun.file("package.json").json() + expect(pkg.dependencies["@opencode-ai/client"]).toBeUndefined() + expect(pkg.devDependencies["@opencode-ai/client"]).toBe("workspace:*") +}) diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5d25be209e..96d7b01f3c 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -1,9 +1,8 @@ import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "electron-vite" import appPlugin from "@opencode-ai/app/vite" -import * as fs from "node:fs/promises" - -const OPENCODE_SERVER_DIST = "../opencode/dist/node" +import { serverVite } from "@opencode-ai/sdk-next/vite" +import { getServerTarget } from "./scripts/target" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -12,7 +11,8 @@ const channel = (() => { return "dev" })() -const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` +const server = serverVite() +const target = getServerTarget() const sentry = process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT @@ -33,12 +33,18 @@ const sentry = export default defineConfig({ main: { + resolve: server.resolve, define: { "import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel), + OPENCODE_LIBC: target.platform === "linux" ? JSON.stringify("glibc") : "undefined", + FFF_LIBC: target.platform === "linux" ? JSON.stringify("gnu") : "undefined", }, build: { + target: "node24", + externalizeDeps: { exclude: ["@opencode-ai/client"] }, rollupOptions: { input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, + external: [/^@opencode-ai\/simulation(?:\/|$)/], // Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can // corrupt bundled TypeScript, while a Rollup banner places the shim safely. output: { @@ -51,33 +57,8 @@ const require = __cjs_mod__.createRequire(import.meta.url); `, }, }, - externalizeDeps: { include: [nodePtyPkg] }, }, - plugins: [ - { - name: "opencode:node-pty-narrower", - enforce: "pre", - resolveId(s) { - if (s === "@lydell/node-pty") return nodePtyPkg - }, - }, - { - name: "opencode:virtual-server-module", - enforce: "pre", - resolveId(id) { - if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`) - }, - }, - { - name: "opencode:copy-server-assets", - async writeBundle() { - for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) { - if (!l.endsWith(".wasm")) continue - await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`)) - } - }, - }, - ], + plugins: server.plugins, }, preload: { build: { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 78e070c4c0..cb2c0a3cdb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -11,6 +11,7 @@ }, "scripts": { "typecheck": "tsgo -b", + "test": "bun test --only-failures", "predev": "bun ./scripts/predev.ts", "dev": "electron-vite dev", "prebuild": "bun ./scripts/prebuild.ts", @@ -36,8 +37,26 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@lydell/node-pty": "catalog:", + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", + "@lydell/node-pty-linux-x64": "1.2.0-beta.12", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", + "@lydell/node-pty-win32-x64": "1.2.0-beta.12", "@opencode-ai/app": "workspace:*", + "@opencode-ai/client": "workspace:*", + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/sdk-next": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", + "@silvia-odwyer/photon-node": "0.3.4", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", @@ -56,21 +75,5 @@ "typescript": "~5.6.2", "vite": "catalog:", "zod-openapi": "5.4.6" - }, - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", - "@lydell/node-pty-linux-x64": "1.2.0-beta.12", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", - "@lydell/node-pty-win32-x64": "1.2.0-beta.12", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" } } diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 79b0e30afc..d452e57267 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -7,4 +7,4 @@ const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` -await $`cd ../opencode && bun script/build-node.ts` +await import("./stage-server-assets") diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 37c31d7eed..07f6b1c054 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -2,4 +2,4 @@ import { $ } from "bun" await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` -await $`cd ../opencode && bun script/build-node.ts` +await import("./stage-server-assets") diff --git a/packages/desktop/scripts/prepare.ts b/packages/desktop/scripts/prepare.ts index 0dfd5a35cb..4ab02541d1 100755 --- a/packages/desktop/scripts/prepare.ts +++ b/packages/desktop/scripts/prepare.ts @@ -1,8 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -await import("./prebuild") - const pkg = await Bun.file("./package.json").json() pkg.version = Script.version await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") diff --git a/packages/desktop/scripts/stage-server-assets.ts b/packages/desktop/scripts/stage-server-assets.ts new file mode 100644 index 0000000000..e0db8abfc7 --- /dev/null +++ b/packages/desktop/scripts/stage-server-assets.ts @@ -0,0 +1,36 @@ +import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { getServerTarget } from "./target" + +const target = getServerTarget() +const root = path.resolve(import.meta.dirname, "../resources/server-assets") +const nodePtyPackage = `@lydell/node-pty-${target.platform}-${target.arch}` +const parcelWatcherPackage = `@parcel/watcher-${target.platform}-${target.arch}${target.platform === "linux" ? "-glibc" : ""}` +const photonWasm = "@silvia-odwyer/photon-node/photon_rs_bg.wasm" +const nodePtyEntry = fileURLToPath(import.meta.resolve(nodePtyPackage)) +const nodePtyRoot = path.resolve(path.dirname(nodePtyEntry), "..") + +await rm(root, { recursive: true, force: true }) +await mkdir(root, { recursive: true }) +await cp(nodePtyRoot, path.join(root, nodePtyPackage), { + recursive: true, + filter: (source) => !source.endsWith(".map") && !source.endsWith(".pdb"), +}) +await mkdir(path.join(root, parcelWatcherPackage), { recursive: true }) +await cp( + fileURLToPath(import.meta.resolve(parcelWatcherPackage)), + path.join(root, parcelWatcherPackage, "watcher.node"), +) +await mkdir(path.join(root, path.dirname(photonWasm)), { recursive: true }) +await cp(fileURLToPath(import.meta.resolve(photonWasm)), path.join(root, photonWasm)) + +if (target.platform === "darwin") { + await chmod(path.join(root, nodePtyPackage, "prebuilds", `darwin-${target.arch}`, "spawn-helper"), 0o755) +} + +await writeFile( + path.join(root, "target.json"), + `${JSON.stringify({ ...target, nodePtyPackage, parcelWatcherPackage }, null, 2)}\n`, +) diff --git a/packages/desktop/scripts/target.ts b/packages/desktop/scripts/target.ts new file mode 100644 index 0000000000..ed2c092563 --- /dev/null +++ b/packages/desktop/scripts/target.ts @@ -0,0 +1,72 @@ +export const SIDECAR_BINARIES: Array<{ + rustTarget: string + ocBinary: string + assetExt: string + platform: "darwin" | "linux" | "win32" + arch: "arm64" | "x64" +}> = [ + { + rustTarget: "aarch64-apple-darwin", + ocBinary: "opencode-darwin-arm64", + assetExt: "zip", + platform: "darwin", + arch: "arm64", + }, + { + rustTarget: "x86_64-apple-darwin", + ocBinary: "opencode-darwin-x64-baseline", + assetExt: "zip", + platform: "darwin", + arch: "x64", + }, + { + rustTarget: "aarch64-pc-windows-msvc", + ocBinary: "opencode-windows-arm64", + assetExt: "zip", + platform: "win32", + arch: "arm64", + }, + { + rustTarget: "x86_64-pc-windows-msvc", + ocBinary: "opencode-windows-x64-baseline", + assetExt: "zip", + platform: "win32", + arch: "x64", + }, + { + rustTarget: "x86_64-unknown-linux-gnu", + ocBinary: "opencode-linux-x64-baseline", + assetExt: "tar.gz", + platform: "linux", + arch: "x64", + }, + { + rustTarget: "aarch64-unknown-linux-gnu", + ocBinary: "opencode-linux-arm64", + assetExt: "tar.gz", + platform: "linux", + arch: "arm64", + }, +] + +export const RUST_TARGET = process.env.RUST_TARGET + +function nativeTarget() { + if (process.platform === "darwin") return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin" + if (process.platform === "win32") + return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc" + if (process.platform === "linux") + return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" + throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`) +} + +export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = SIDECAR_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) + return binaryConfig +} + +export function getServerTarget(target = RUST_TARGET ?? nativeTarget()) { + const current = getCurrentSidecar(target) + return { rustTarget: current.rustTarget, platform: current.platform, arch: current.arch } +} diff --git a/packages/desktop/scripts/utils.test.ts b/packages/desktop/scripts/utils.test.ts new file mode 100644 index 0000000000..bd10811325 --- /dev/null +++ b/packages/desktop/scripts/utils.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" + +import { SIDECAR_BINARIES, getServerTarget } from "./target" +import packageManifest from "../package.json" + +test("derives the Windows ARM64 server assets from the release target", () => { + expect(getServerTarget("aarch64-pc-windows-msvc")).toEqual({ + rustTarget: "aarch64-pc-windows-msvc", + platform: "win32", + arch: "arm64", + }) +}) + +test("derives each packaged server architecture independently of the runner", () => { + expect(getServerTarget("x86_64-apple-darwin")).toMatchObject({ platform: "darwin", arch: "x64" }) + expect(getServerTarget("aarch64-unknown-linux-gnu")).toMatchObject({ platform: "linux", arch: "arm64" }) +}) + +test("declares every target-native server dependency directly", () => { + const dependencies = packageManifest.devDependencies + for (const target of SIDECAR_BINARIES) { + expect(dependencies[`@lydell/node-pty-${target.platform}-${target.arch}`]).toBeDefined() + const watcher = `@parcel/watcher-${target.platform}-${target.arch}${target.platform === "linux" ? "-glibc" : ""}` + expect(dependencies[watcher]).toBeDefined() + } + expect(dependencies["@parcel/watcher-darwin-x64"]).toBe("2.5.1") + expect(dependencies["@silvia-odwyer/photon-node"]).toBe("0.3.4") +}) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 19b96b0a16..5639b835bc 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,5 +1,7 @@ import { $ } from "bun" +export { getCurrentSidecar, getServerTarget, RUST_TARGET, SIDECAR_BINARIES } from "./target" + export type Channel = "dev" | "beta" | "prod" export function resolveChannel(): Channel { @@ -8,56 +10,6 @@ export function resolveChannel(): Channel { return "dev" } -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ - { - rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", - }, - { - rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", - }, - { - rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", - }, - { - rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", - }, - { - rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", - }, - { - rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", - }, -] - -export const RUST_TARGET = Bun.env.RUST_TARGET - -function nativeTarget() { - const { platform, arch } = process - if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin" - if (platform === "win32") return arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc" - if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" - throw new Error(`Unsupported platform: ${platform}/${arch}`) -} - -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) - - return binaryConfig -} - export async function copyBinaryToSidecarFolder(source: string) { const dir = `resources` await $`mkdir -p ${dir}` diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index d69e6feec2..0ee0c551df 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -5,15 +5,3 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } - -declare module "virtual:opencode-server" { - export namespace Server { - export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen - export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener - } - export namespace Config { - export const get: typeof import("../../../opencode/dist/types/src/node").Config.get - export type Info = import("../../../opencode/dist/types/src/node").Config.Info - } - export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap -} diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 7fab6bc847..9e5aa79478 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -47,6 +47,9 @@ export function preferAppEnv(userDataPath: string) { ...(shell ? loadShellEnv(shell, getLogger()) : null), OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_CLIENT: "desktop", + OPENCODE_NODE_PTY_PATH: + process.env.OPENCODE_NODE_PTY_PATH ?? + join(serverAssetsPath(), `@lydell/node-pty-${process.platform}-${process.arch}`, "lib/index.js"), XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) } @@ -132,6 +135,9 @@ export async function spawnLocalServer( port, password, userDataPath: options.userDataPath, + version: app.getVersion(), + channel: import.meta.env.OPENCODE_CHANNEL, + serverAssetsPath: serverAssetsPath(), }) }).catch((error) => { if (!exited) child.kill() @@ -166,13 +172,12 @@ export async function spawnLocalServer( stop: () => { if (stopping) return stopping if (exited) return Promise.resolve() - child.postMessage({ type: "stop" }) - stopping = Promise.race([ - exit.promise.then(() => undefined), - delay(SIDECAR_STOP_TIMEOUT).then(() => { - if (!exited) child.kill() - }), - ]) + stopping = (async () => { + child.postMessage({ type: "stop" }) + if (await settlesWithin(exit.promise, SIDECAR_STOP_TIMEOUT)) return + if (!exited) child.kill() + await exit.promise + })() return stopping }, }, @@ -180,6 +185,12 @@ export async function spawnLocalServer( } } +function serverAssetsPath() { + return app.isPackaged + ? join(process.resourcesPath, "server-assets") + : join(app.getAppPath(), "resources/server-assets") +} + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrls: URL[] try { @@ -216,8 +227,15 @@ function createSidecarEnv(): Record { return env } -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) +function settlesWithin(promise: Promise, ms: number) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), ms) + timeout.unref() + void promise.then(() => { + clearTimeout(timeout) + resolve(true) + }) + }) } function serializeError(error: unknown) { diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 246871fb2b..22176b160d 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -1,5 +1,6 @@ import * as http from "node:http" import * as tls from "node:tls" +import path from "node:path" type NodeHttpWithEnvProxy = typeof http & { setGlobalProxyFromEnv: () => void @@ -16,6 +17,9 @@ type StartCommand = { port: number password: string userDataPath: string + version: string + channel: string + serverAssetsPath: string } type StopCommand = { type: "stop" } @@ -50,18 +54,17 @@ parentPort.on("message", (event) => { async function start(command: StartCommand) { try { - prepareSidecarEnv(command.password, command.userDataPath) + prepareSidecarEnv(command) ensureLoopbackNoProxy() useSystemCertificates() useEnvProxy() - const { Server } = await import("virtual:opencode-server") + const { Server } = await import("@opencode-ai/sdk-next/server") listener = await Server.listen({ + app: { name: "desktop", version: command.version, channel: command.channel }, port: command.port, hostname: command.hostname, - username: "opencode", password: command.password, - cors: ["oc://renderer"], }) parentPort.postMessage({ type: "ready" }) } catch (error) { @@ -80,11 +83,17 @@ async function stop() { } } -function prepareSidecarEnv(password: string, userDataPath: string) { +function prepareSidecarEnv(command: StartCommand) { + const nodePtyPackage = `@lydell/node-pty-${process.platform}-${process.arch}` + const parcelWatcherPackage = `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? "-glibc" : ""}` Object.assign(process.env, { OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + OPENCODE_SERVER_PASSWORD: command.password, + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? command.userDataPath, + OPENCODE_NODE_ASSETS_DIR: command.serverAssetsPath, + OPENCODE_NODE_PTY_PATH: path.join(command.serverAssetsPath, nodePtyPackage, "lib/index.js"), + OPENCODE_PARCEL_WATCHER_PATH: path.join(command.serverAssetsPath, parcelWatcherPackage, "watcher.node"), + OPENCODE_PHOTON_WASM_PATH: path.join(command.serverAssetsPath, "@silvia-odwyer/photon-node/photon_rs_bg.wasm"), }) } @@ -136,12 +145,18 @@ function parseCommand(value: unknown): SidecarCommand | undefined { if (typeof command.port !== "number") return if (typeof command.password !== "string") return if (typeof command.userDataPath !== "string") return + if (typeof command.version !== "string") return + if (typeof command.channel !== "string") return + if (typeof command.serverAssetsPath !== "string") return return { type: "start", hostname: command.hostname, port: command.port, password: command.password, userDataPath: command.userDataPath, + version: command.version, + channel: command.channel, + serverAssetsPath: command.serverAssetsPath, } } diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index 458443731b..87e69a4371 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process" import { existsSync } from "node:fs" +import { createRequire } from "node:module" import { join } from "node:path" -import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" import { wslTerminalArgs } from "./policy" @@ -32,6 +32,7 @@ export type RunWslOptions = { const DEFAULT_WSL_TIMEOUT_MS = 20_000 const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 +let nodePty: typeof import("@lydell/node-pty") | undefined export function wslArgs(args: string[], distro?: string | null, user?: string | null) { return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args] @@ -112,7 +113,7 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) { return new Promise((resolve, reject) => { - const child = pty.spawn(command, args, { + const child = loadNodePty().spawn(command, args, { name: "xterm-color", cols: 80, rows: 24, @@ -172,6 +173,15 @@ function runInteractiveCommand(command: string, args: string[], opts: RunWslOpti }) } +function loadNodePty() { + if (nodePty) return nodePty + const loaded: typeof import("@lydell/node-pty") = createRequire(import.meta.url)( + process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty", + ) + nodePty = loaded + return loaded +} + function createOutputDecoder() { let decoder: TextDecoder | undefined return { diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 8ef199b680..5ea521e9aa 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -5,13 +5,19 @@ "type": "module", "license": "MIT", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./server": { + "types": "./src/server-interface.ts", + "default": "./src/server.ts" + }, + "./vite": "./src/vite.ts" }, "scripts": { "test": "bun test --timeout 5000", "typecheck": "tsgo --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -24,6 +30,8 @@ "@opencode-ai/protocol": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "vite": "catalog:", + "ws": "8.21.0" } } diff --git a/packages/sdk-next/src/plugin-runtime.effect.ts b/packages/sdk-next/src/plugin-runtime.effect.ts new file mode 100644 index 0000000000..d17fab342f --- /dev/null +++ b/packages/sdk-next/src/plugin-runtime.effect.ts @@ -0,0 +1,28 @@ +import { + Agent, + Command, + Connection, + Credential, + Integration, + Model, + Plugin, + Provider, + Reference, + Skill, +} from "@opencode-ai/plugin/effect" +import { Tool } from "@opencode-ai/schema/tool" + +const key = Symbol.for("opencode.plugin.v2.effect") +;(globalThis as typeof globalThis & { [key]?: unknown })[key] = { + Agent, + Command, + Connection, + Credential, + Integration, + Model, + Plugin, + Provider, + Reference, + Skill, + Tool: { Error: Tool.Error }, +} diff --git a/packages/sdk-next/src/plugin-runtime.promise.ts b/packages/sdk-next/src/plugin-runtime.promise.ts new file mode 100644 index 0000000000..fa33d6edae --- /dev/null +++ b/packages/sdk-next/src/plugin-runtime.promise.ts @@ -0,0 +1,26 @@ +import { + Agent, + Command, + Connection, + Credential, + Integration, + Model, + Plugin, + Provider, + Reference, + Skill, +} from "@opencode-ai/plugin" + +const key = Symbol.for("opencode.plugin.v2.promise") +;(globalThis as typeof globalThis & { [key]?: unknown })[key] = { + Agent, + Command, + Connection, + Credential, + Integration, + Model, + Plugin, + Provider, + Reference, + Skill, +} diff --git a/packages/sdk-next/src/plugin-runtime.ts b/packages/sdk-next/src/plugin-runtime.ts new file mode 100644 index 0000000000..0f143d139b --- /dev/null +++ b/packages/sdk-next/src/plugin-runtime.ts @@ -0,0 +1,61 @@ +import "./plugin-runtime.promise" +import "./plugin-runtime.effect" + +import { registerHooks } from "node:module" + +const key = Symbol.for("opencode.sdk-next.plugin-runtime") +const globals = globalThis as typeof globalThis & Record + +if (!globals[key]) { + globals[key] = true + const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")] +if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable") +export const Agent = sdk.Agent +export const Command = sdk.Command +export const Connection = sdk.Connection +export const Credential = sdk.Credential +export const Integration = sdk.Integration +export const Model = sdk.Model +export const Plugin = sdk.Plugin +export const Provider = sdk.Provider +export const Reference = sdk.Reference +export const Skill = sdk.Skill` + const effectModule = promiseModule + .replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect") + .replace("Promise plugin", "Effect plugin") + const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")] +if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable") +export const define = sdk.Plugin.define` + const effectPluginModule = promisePluginModule + .replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect") + .replace("Promise plugin", "Effect plugin") + const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")] +if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable") +export const Error = sdk.Tool.Error` + const modules: Record = { + "@opencode-ai/plugin": "opencode:plugin-v2", + "@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin", + "@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool", + "@opencode-ai/plugin/effect": "opencode:plugin-v2-effect", + "@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin", + "@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool", + } + const sources: Record = { + "opencode:plugin-v2": promiseModule, + "opencode:plugin-promise-plugin": promisePluginModule, + "opencode:plugin-promise-tool": "export {}", + "opencode:plugin-v2-effect": effectModule, + "opencode:plugin-v2-effect-plugin": effectPluginModule, + "opencode:plugin-v2-effect-tool": effectToolModule, + } + registerHooks({ + resolve(specifier, context, nextResolve) { + const url = modules[specifier] + return url ? { url, shortCircuit: true } : nextResolve(specifier, context) + }, + load(url, context, nextLoad) { + const source = sources[url] + return source ? { format: "module", source, shortCircuit: true } : nextLoad(url, context) + }, + }) +} diff --git a/packages/sdk-next/src/server-interface.ts b/packages/sdk-next/src/server-interface.ts new file mode 100644 index 0000000000..1452369a0f --- /dev/null +++ b/packages/sdk-next/src/server-interface.ts @@ -0,0 +1,16 @@ +export type ListenOptions = { + readonly app: { + readonly name: string + readonly version: string + readonly channel: string + } + readonly hostname: string + readonly port: number + readonly password: string +} + +export type Listener = { readonly stop: () => Promise } +export type Listen = (options: ListenOptions) => Promise + +export declare const listen: Listen +export declare const Server: { readonly listen: Listen } diff --git a/packages/sdk-next/src/server.ts b/packages/sdk-next/src/server.ts new file mode 100644 index 0000000000..9da3657b3d --- /dev/null +++ b/packages/sdk-next/src/server.ts @@ -0,0 +1,79 @@ +import "./plugin-runtime" + +import { NodeServices } from "@effect/platform-node" +import { ServerProcess } from "@opencode-ai/server/process" +import { Effect, Exit, ManagedRuntime, Scope } from "effect" +import { randomUUID } from "node:crypto" +import type { Listen, ListenOptions } from "./server-interface" + +export const listen: Listen = async (options) => { + const runtime = ManagedRuntime.make(NodeServices.layer) + const scope = await runtime.runPromise(Scope.make()) + await runtime + .runPromise( + ServerProcess.start(fromEnvironment(options), { + instanceID: randomUUID(), + onListen: () => Effect.succeed(Effect.void), + }).pipe(Effect.provideService(Scope.Scope, scope)), + ) + .catch(async (error) => { + await runtime + .runPromise(Scope.close(scope, Exit.fail(error))) + .finally(() => Effect.runPromise(runtime.disposeEffect)) + throw error + }) + let stopped = false + return { + stop: async () => { + if (stopped) return + stopped = true + await runtime.runPromise(Scope.close(scope, Exit.void)).finally(() => Effect.runPromise(runtime.disposeEffect)) + }, + } +} + +function fromEnvironment(options: ListenOptions, environment = process.env) { + const channel = options.app.channel + return { + ...options, + simulation: truthy(environment.OPENCODE_SIMULATE), + database: { + path: + environment.OPENCODE_DB ?? + (["latest", "beta", "prod"].includes(channel) || truthy(environment.OPENCODE_DISABLE_CHANNEL_DB) + ? "opencode.db" + : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`), + }, + models: { + url: environment.OPENCODE_MODELS_URL, + file: environment.OPENCODE_MODELS_PATH, + fetch: !truthy(environment.OPENCODE_DISABLE_MODELS_FETCH), + }, + observability: { + endpoint: environment.OTEL_EXPORTER_OTLP_ENDPOINT, + headers: environment.OTEL_EXPORTER_OTLP_HEADERS, + }, + config: { + directory: environment.OPENCODE_CONFIG_DIR, + project: !truthy(environment.OPENCODE_CONFIG_PROJECT_DISABLE ?? environment.OPENCODE_DISABLE_PROJECT_CONFIG), + file: environment.OPENCODE_CONFIG, + content: environment.OPENCODE_CONFIG_CONTENT, + }, + windows: { + gitbash: environment.OPENCODE_GIT_BASH_PATH, + }, + fs: { + filewatcher: !truthy(environment.OPENCODE_FILEWATCHER_DISABLE ?? environment.OPENCODE_DISABLE_FILEWATCHER), + fff: + environment.OPENCODE_DISABLE_FFF === undefined + ? process.platform !== "win32" + : !truthy(environment.OPENCODE_DISABLE_FFF), + }, + } +} + +function truthy(value?: string) { + return value === "1" || value?.toLowerCase() === "true" +} + +export const Server = { listen } diff --git a/packages/sdk-next/src/vite.ts b/packages/sdk-next/src/vite.ts new file mode 100644 index 0000000000..ef84163f44 --- /dev/null +++ b/packages/sdk-next/src/vite.ts @@ -0,0 +1,43 @@ +import { readFile } from "node:fs/promises" +import { createRequire } from "node:module" +import path from "node:path" +import type { Plugin } from "vite" + +export function serverVite() { + const plugins: Plugin[] = [ + { + name: "opencode:raw-text", + async load(id) { + if (!id.endsWith(".md")) return + return `export default ${JSON.stringify(await readFile(id, "utf8"))}` + }, + }, + { + name: "opencode:runtime-require", + enforce: "pre", + transform(code, id) { + if (!id.endsWith("turndown/lib/turndown.es.js")) return + const transformed = code.replace(" var domino = require('@mixmark-io/domino');", "") + if (transformed === code) this.error("Failed to rewrite Turndown's Domino require") + return `import domino from "@mixmark-io/domino"\n${transformed}` + }, + }, + ] + return { + plugins, + resolve: { + alias: [ + { find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" }, + { find: /^solid-js$/, replacement: "solid-js/dist/solid.js" }, + { + find: /^ws$/, + replacement: path.join( + path.dirname(createRequire(import.meta.url).resolve("ws/package.json")), + "wrapper.mjs", + ), + }, + ], + conditions: ["node"], + }, + } +} diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts index f6a7d178e2..37fca5f23a 100644 --- a/packages/sdk-next/test/import-boundaries.test.ts +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -8,19 +8,26 @@ const core = resolve(import.meta.dir, "../../core") const server = resolve(import.meta.dir, "../../server") test("bundles the client and in-memory host", async () => { - const inputs = await bundleInputs() + const inputs = await bundleInputs("@opencode-ai/sdk-next") expect(within(inputs, client).length).toBeGreaterThan(0) expect(within(inputs, core).length).toBeGreaterThan(0) expect(within(inputs, server).length).toBeGreaterThan(0) }) -async function bundleInputs() { +test("declares the Node server composition entrypoint", async () => { + const inputs = await bundleInputs("@opencode-ai/sdk-next/server") + + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs(specifier: string) { const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) const entrypoint = join(temporary, "index.ts") const metafile = join(temporary, "meta.json") try { - await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`) const child = Bun.spawn( [ process.execPath, diff --git a/turbo.json b/turbo.json index cd139edb2e..67cbde9c78 100644 --- a/turbo.json +++ b/turbo.json @@ -28,6 +28,10 @@ "dependsOn": ["^build"], "outputs": [] }, + "@opencode-ai/desktop#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/ui#test": { "dependsOn": ["^build"], "outputs": []