diff --git a/.opencode/skills/openwrk-npm-publish/SKILL.md b/.opencode/skills/openwrk-npm-publish/SKILL.md index cb6fbaf9..cd4c9490 100644 --- a/.opencode/skills/openwrk-npm-publish/SKILL.md +++ b/.opencode/skills/openwrk-npm-publish/SKILL.md @@ -14,13 +14,23 @@ description: | 1. Ensure you are on the default branch and the tree is clean. 2. Bump the version in `packages/headless/package.json`. 3. Commit the bump. -4. Publish the package. +4. Build sidecar artifacts and publish them to a release tag. + +```bash +pnpm --filter openwrk build:sidecars +gh release create openwrk-vX.Y.Z packages/headless/dist/sidecars/* \ + --repo different-ai/openwork \ + --title "openwrk vX.Y.Z sidecars" \ + --notes "Sidecar binaries and manifest for openwrk vX.Y.Z" +``` + +5. Publish the package. ```bash pnpm --filter openwrk publish --access public ``` -5. Verify the published version. +6. Verify the published version. ```bash npm view openwrk version @@ -52,3 +62,4 @@ Alternatively, export an npm token in your environment (see `.env.example`). - `pnpm publish` requires a clean git tree. - This publish flow is separate from app release tags. +- openwrk downloads sidecars from `openwrk-vX.Y.Z` release assets by default. diff --git a/AGENTS.md b/AGENTS.md index 527ea4a1..cd203422 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,10 @@ This is separate from app release tags. Use `.opencode/skills/openwrk-npm-publis 1. Ensure the default branch is up to date and clean. 2. Bump `packages/headless/package.json` (`version`). 3. Commit the bump. -4. Publish: +4. Build and upload sidecar assets for the same version tag: + * `pnpm --filter openwrk build:sidecars` + * `gh release create openwrk-vX.Y.Z packages/headless/dist/sidecars/* --repo different-ai/openwork` +5. Publish: * `pnpm --filter openwrk publish --access public` -5. Verify: +6. Verify: * `npm view openwrk version` diff --git a/packages/headless/README.md b/packages/headless/README.md index b27fea23..0e74d3b1 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -11,9 +11,12 @@ openwrk start --workspace /path/to/workspace --approval auto `openwrk` ships as a compiled binary, so Bun is not required at runtime. -`openwrk` bundles and validates exact versions of `openwork-server` + `owpenbot` from the -monorepo using a SHA-256 manifest. It will refuse to start if the bundled binaries are missing -or tampered with. +`openwrk` downloads and caches the `openwork-server`, `owpenbot`, and `opencode` sidecars on +first run using a SHA-256 manifest. Use `--sidecar-dir` or `OPENWRK_SIDECAR_DIR` to control the +cache location, and `--sidecar-base-url` / `--sidecar-manifest` to point at a custom host. + +By default the manifest is fetched from +`https://github.com/different-ai/openwork/releases/download/openwrk-v/openwrk-sidecars.json`. Owpenbot is optional. If it exits, `openwrk` continues running unless you pass `--owpenbot-required` or set `OPENWRK_OWPENBOT_REQUIRED=1`. diff --git a/packages/headless/package.json b/packages/headless/package.json index 39397c2d..42520d96 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -3,23 +3,23 @@ "version": "0.1.11", "description": "Headless OpenWork host orchestrator for OpenCode + OpenWork server + Owpenbot", "type": "module", + "opencodeVersion": "1.1.45", "bin": { "openwrk": "dist/openwrk" }, "scripts": { "dev": "bun src/cli.ts", "build": "tsc -p tsconfig.json", - "build:bin": "node ../desktop/scripts/prepare-sidecar.mjs --outdir dist && bun build --compile src/cli.ts --outfile dist/openwrk", + "build:bin": "node scripts/clean-dist.mjs && bun build --compile src/cli.ts --outfile dist/openwrk", + "build:bin:bundled": "node scripts/clean-dist.mjs && node ../desktop/scripts/prepare-sidecar.mjs --outdir dist && bun build --compile src/cli.ts --outfile dist/openwrk && bun scripts/build-bin.ts", + "build:sidecars": "node scripts/build-sidecars.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test:router": "pnpm build && node scripts/router.mjs", "prepublishOnly": "pnpm build:bin" }, "files": [ "dist", - "README.md", - "dist/openwork-server", - "dist/owpenbot", - "dist/versions.json" + "README.md" ], "repository": { "type": "git", diff --git a/packages/headless/scripts/build-sidecars.mjs b/packages/headless/scripts/build-sidecars.mjs new file mode 100644 index 00000000..bc2d98e6 --- /dev/null +++ b/packages/headless/scripts/build-sidecars.mjs @@ -0,0 +1,96 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const repoRoot = resolve(root, "..", ".."); +const outdir = resolve(root, "dist", "sidecars"); + +const openwrkPkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); +const openwrkVersion = String(openwrkPkg.version ?? "").trim(); +if (!openwrkVersion) { + throw new Error("openwrk version missing in packages/headless/package.json"); +} + +const serverPkg = JSON.parse(readFileSync(resolve(repoRoot, "packages", "server", "package.json"), "utf8")); +const serverVersion = String(serverPkg.version ?? "").trim(); +if (!serverVersion) { + throw new Error("openwork-server version missing in packages/server/package.json"); +} + +const owpenbotPkg = JSON.parse(readFileSync(resolve(repoRoot, "packages", "owpenbot", "package.json"), "utf8")); +const owpenbotVersion = String(owpenbotPkg.version ?? "").trim(); +if (!owpenbotVersion) { + throw new Error("owpenbot version missing in packages/owpenbot/package.json"); +} + +const run = (command, args, cwd) => { + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +}; + +run("pnpm", ["--filter", "openwork-server", "build:bin:all"], repoRoot); +run("pnpm", ["--filter", "owpenwork", "build:bin:all"], repoRoot); + +const targets = [ + { id: "darwin-arm64", bun: "bun-darwin-arm64" }, + { id: "darwin-x64", bun: "bun-darwin-x64" }, + { id: "linux-x64", bun: "bun-linux-x64" }, + { id: "linux-arm64", bun: "bun-linux-arm64" }, + { id: "windows-x64", bun: "bun-windows-x64" }, +]; + +const sha256File = (path) => { + const data = readFileSync(path); + return createHash("sha256").update(data).digest("hex"); +}; + +const serverDir = resolve(repoRoot, "packages", "server", "dist", "bin"); +const owpenbotDir = resolve(repoRoot, "packages", "owpenbot", "dist", "bin"); + +mkdirSync(outdir, { recursive: true }); + +const entries = { + "openwork-server": { version: serverVersion, targets: {} }, + owpenbot: { version: owpenbotVersion, targets: {} }, +}; + +for (const target of targets) { + const ext = target.id.startsWith("windows") ? ".exe" : ""; + const serverSrc = join(serverDir, `openwork-server-${target.bun}${ext}`); + if (!existsSync(serverSrc)) { + throw new Error(`Missing openwork-server binary at ${serverSrc}`); + } + const serverDest = join(outdir, `openwork-server-${target.id}${ext}`); + copyFileSync(serverSrc, serverDest); + + const owpenbotSrc = join(owpenbotDir, `owpenbot-${target.bun}${ext}`); + if (!existsSync(owpenbotSrc)) { + throw new Error(`Missing owpenbot binary at ${owpenbotSrc}`); + } + const owpenbotDest = join(outdir, `owpenbot-${target.id}${ext}`); + copyFileSync(owpenbotSrc, owpenbotDest); + + entries["openwork-server"].targets[target.id] = { + asset: basename(serverDest), + sha256: sha256File(serverDest), + size: statSync(serverDest).size, + }; + entries.owpenbot.targets[target.id] = { + asset: basename(owpenbotDest), + sha256: sha256File(owpenbotDest), + size: statSync(owpenbotDest).size, + }; +} + +const manifest = { + version: openwrkVersion, + generatedAt: new Date().toISOString(), + entries, +}; + +writeFileSync(join(outdir, "openwrk-sidecars.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); diff --git a/packages/headless/scripts/clean-dist.mjs b/packages/headless/scripts/clean-dist.mjs new file mode 100644 index 00000000..1754df0b --- /dev/null +++ b/packages/headless/scripts/clean-dist.mjs @@ -0,0 +1,6 @@ +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +await rm(resolve(root, "dist"), { recursive: true, force: true }); diff --git a/packages/headless/src/cli.ts b/packages/headless/src/cli.ts index a6a64f6c..773ff89a 100644 --- a/packages/headless/src/cli.ts +++ b/packages/headless/src/cli.ts @@ -1,12 +1,11 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; +import { randomUUID, createHash } from "node:crypto"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { createServer as createNetServer } from "node:net"; import { createServer as createHttpServer } from "node:http"; -import { homedir, hostname, networkInterfaces } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { homedir, hostname, networkInterfaces, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { access } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -38,12 +37,45 @@ type VersionInfo = { type SidecarName = "openwork-server" | "owpenbot" | "opencode"; +type SidecarTarget = + | "darwin-arm64" + | "darwin-x64" + | "linux-x64" + | "linux-arm64" + | "windows-x64" + | "windows-arm64"; + type VersionManifest = { dir: string; entries: Record; }; -type BinarySource = "bundled" | "external"; +type RemoteSidecarAsset = { + asset?: string; + url?: string; + sha256?: string; + size?: number; +}; + +type RemoteSidecarEntry = { + version: string; + targets: Record; +}; + +type RemoteSidecarManifest = { + version: string; + generatedAt?: string; + entries: Record; +}; + +type SidecarConfig = { + dir: string; + baseUrl: string; + manifestUrl: string; + target: SidecarTarget | null; +}; + +type BinarySource = "bundled" | "external" | "downloaded"; type ResolvedBinary = { bin: string; @@ -239,6 +271,28 @@ async function resolveCliVersion(): Promise { return FALLBACK_VERSION; } +async function readPackageField(field: string): Promise { + const candidates = [ + join(dirname(process.execPath), "..", "package.json"), + join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), + ]; + + for (const candidate of candidates) { + if (await fileExists(candidate)) { + try { + const raw = await readFile(candidate, "utf8"); + const parsed = JSON.parse(raw) as Record; + const value = parsed[field]; + if (typeof value === "string" && value.trim()) return value.trim(); + } catch { + // ignore + } + } + } + + return undefined; +} + async function isExecutable(path: string): Promise { try { await access(path); @@ -418,6 +472,256 @@ async function readVersionManifest(): Promise { return null; } +const remoteManifestCache = new Map>(); + +function resolveSidecarTarget(): SidecarTarget | null { + if (process.platform === "darwin") { + if (process.arch === "arm64") return "darwin-arm64"; + if (process.arch === "x64") return "darwin-x64"; + return null; + } + if (process.platform === "linux") { + if (process.arch === "arm64") return "linux-arm64"; + if (process.arch === "x64") return "linux-x64"; + return null; + } + if (process.platform === "win32") { + if (process.arch === "arm64") return "windows-arm64"; + if (process.arch === "x64") return "windows-x64"; + return null; + } + return null; +} + +function resolveSidecarDir(flags: Map): string { + const override = + readFlag(flags, "sidecar-dir") ?? + process.env.OPENWRK_SIDECAR_DIR ?? + process.env.OPENWORK_SIDECAR_DIR; + if (override && override.trim()) return resolve(override.trim()); + return join(resolveRouterDataDir(flags), "sidecars"); +} + +function resolveSidecarBaseUrl(flags: Map, cliVersion: string): string { + const override = readFlag(flags, "sidecar-base-url") ?? process.env.OPENWRK_SIDECAR_BASE_URL; + if (override && override.trim()) return override.trim(); + return `https://github.com/different-ai/openwork/releases/download/openwrk-v${cliVersion}`; +} + +function resolveSidecarManifestUrl(flags: Map, baseUrl: string): string { + const override = readFlag(flags, "sidecar-manifest") ?? process.env.OPENWRK_SIDECAR_MANIFEST_URL; + if (override && override.trim()) return override.trim(); + return `${baseUrl.replace(/\/$/, "")}/openwrk-sidecars.json`; +} + +function resolveSidecarConfig(flags: Map, cliVersion: string): SidecarConfig { + const baseUrl = resolveSidecarBaseUrl(flags, cliVersion); + return { + dir: resolveSidecarDir(flags), + baseUrl, + manifestUrl: resolveSidecarManifestUrl(flags, baseUrl), + target: resolveSidecarTarget(), + }; +} + +async function fetchRemoteManifest(url: string): Promise { + const cached = remoteManifestCache.get(url); + if (cached) return cached; + const task = (async () => { + try { + const response = await fetch(url); + if (!response.ok) return null; + return (await response.json()) as RemoteSidecarManifest; + } catch { + return null; + } + })(); + remoteManifestCache.set(url, task); + return task; +} + +function resolveAssetUrl(baseUrl: string, asset?: string, url?: string): string | null { + if (url && url.trim()) return url.trim(); + if (asset && asset.trim()) return `${baseUrl.replace(/\/$/, "")}/${asset.trim()}`; + return null; +} + +function resolveAssetName(asset?: string, url?: string): string | null { + if (asset && asset.trim()) return asset.trim(); + if (url && url.trim()) { + try { + return basename(new URL(url).pathname); + } catch { + const parts = url.split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : null; + } + } + return null; +} + +async function downloadToPath(url: string, dest: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url} (HTTP ${response.status})`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await mkdir(dirname(dest), { recursive: true }); + const tmpPath = `${dest}.tmp-${randomUUID()}`; + await writeFile(tmpPath, buffer); + await rename(tmpPath, dest); +} + +async function ensureExecutable(path: string): Promise { + if (process.platform === "win32") return; + try { + await chmod(path, 0o755); + } catch { + // ignore + } +} + +async function downloadSidecarBinary(options: { + name: SidecarName; + sidecar: SidecarConfig; +}): Promise { + if (!options.sidecar.target) return null; + const manifest = await fetchRemoteManifest(options.sidecar.manifestUrl); + if (!manifest) return null; + const entry = manifest.entries[options.name]; + if (!entry) return null; + const targetInfo = entry.targets[options.sidecar.target]; + if (!targetInfo) return null; + + const assetName = resolveAssetName(targetInfo.asset, targetInfo.url); + const assetUrl = resolveAssetUrl(options.sidecar.baseUrl, targetInfo.asset, targetInfo.url); + if (!assetName || !assetUrl) return null; + + const targetDir = join(options.sidecar.dir, entry.version, options.sidecar.target); + const targetPath = join(targetDir, assetName); + if (await fileExists(targetPath)) { + if (targetInfo.sha256) { + try { + await verifyBinary(targetPath, { version: entry.version, sha256: targetInfo.sha256 }); + await ensureExecutable(targetPath); + return { bin: targetPath, source: "downloaded", expectedVersion: entry.version }; + } catch { + await rm(targetPath, { force: true }); + } + } else { + await ensureExecutable(targetPath); + return { bin: targetPath, source: "downloaded", expectedVersion: entry.version }; + } + } + + await downloadToPath(assetUrl, targetPath); + if (targetInfo.sha256) { + await verifyBinary(targetPath, { version: entry.version, sha256: targetInfo.sha256 }); + } + await ensureExecutable(targetPath); + return { bin: targetPath, source: "downloaded", expectedVersion: entry.version }; +} + +function resolveOpencodeAsset(target: SidecarTarget): string | null { + const assets: Record = { + "darwin-arm64": "opencode-darwin-arm64.zip", + "darwin-x64": "opencode-darwin-x64-baseline.zip", + "linux-x64": "opencode-linux-x64-baseline.tar.gz", + "linux-arm64": "opencode-linux-arm64.tar.gz", + "windows-x64": "opencode-windows-x64-baseline.zip", + "windows-arm64": "opencode-windows-arm64.zip", + }; + return assets[target] ?? null; +} + +async function runCommand(command: string, args: string[], cwd?: string): Promise { + const child = spawn(command, args, { cwd, stdio: "inherit" }); + const result = await Promise.race([ + once(child, "exit").then(([code]) => ({ type: "exit", code })), + once(child, "error").then(([error]) => ({ type: "error", error })), + ]); + if (result.type === "error") { + throw new Error(`Command failed: ${command} ${args.join(" ")}: ${String(result.error)}`); + } + if (result.code !== 0) { + throw new Error(`Command failed: ${command} ${args.join(" ")}`); + } +} + +async function resolveOpencodeDownload(sidecar: SidecarConfig, expectedVersion?: string): Promise { + if (!expectedVersion) return null; + if (!sidecar.target) return null; + + const assetOverride = process.env.OPENWRK_OPENCODE_ASSET ?? process.env.OPENCODE_ASSET; + const asset = assetOverride?.trim() || resolveOpencodeAsset(sidecar.target); + if (!asset) return null; + + const version = expectedVersion.startsWith("v") ? expectedVersion.slice(1) : expectedVersion; + const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${asset}`; + const targetDir = join(sidecar.dir, "opencode", version, sidecar.target); + const targetPath = join(targetDir, process.platform === "win32" ? "opencode.exe" : "opencode"); + + if (await fileExists(targetPath)) { + const actual = await readCliVersion(targetPath); + if (actual === version) { + await ensureExecutable(targetPath); + return targetPath; + } + } + + await mkdir(targetDir, { recursive: true }); + const stamp = Date.now(); + const archivePath = join(tmpdir(), `openwrk-opencode-${stamp}-${asset}`); + const extractDir = await mkdtemp(join(tmpdir(), "openwrk-opencode-")); + + try { + await downloadToPath(url, archivePath); + if (process.platform === "win32") { + const psQuote = (value: string) => `'${value.replace(/'/g, "''")}'`; + const psScript = [ + "$ErrorActionPreference = 'Stop'", + `Expand-Archive -Path ${psQuote(archivePath)} -DestinationPath ${psQuote(extractDir)} -Force`, + ].join("; "); + await runCommand("powershell", ["-NoProfile", "-Command", psScript]); + } else if (asset.endsWith(".zip")) { + await runCommand("unzip", ["-q", archivePath, "-d", extractDir]); + } else if (asset.endsWith(".tar.gz")) { + await runCommand("tar", ["-xzf", archivePath, "-C", extractDir]); + } else { + throw new Error(`Unsupported opencode asset type: ${asset}`); + } + + const entries = await readdir(extractDir, { withFileTypes: true }); + const queue = entries.map((entry) => join(extractDir, entry.name)); + let candidate: string | null = null; + while (queue.length) { + const current = queue.shift(); + if (!current) break; + const statInfo = await stat(current); + if (statInfo.isDirectory()) { + const nested = await readdir(current, { withFileTypes: true }); + queue.push(...nested.map((entry) => join(current, entry.name))); + continue; + } + const base = basename(current); + if (base === "opencode" || base === "opencode.exe") { + candidate = current; + break; + } + } + + if (!candidate) { + throw new Error("OpenCode binary not found after extraction."); + } + + await copyFile(candidate, targetPath); + await ensureExecutable(targetPath); + return targetPath; + } finally { + await rm(extractDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + } +} + async function sha256File(path: string): Promise { const data = await readFile(path); return createHash("sha256").update(data).digest("hex"); @@ -493,6 +797,8 @@ async function resolveExpectedVersion( if (name === "opencode") { const envVersion = process.env.OPENCODE_VERSION?.trim(); if (envVersion) return envVersion.startsWith("v") ? envVersion.slice(1) : envVersion; + const pkgVersion = await readPackageField("opencodeVersion"); + if (pkgVersion) return pkgVersion.startsWith("v") ? pkgVersion.slice(1) : pkgVersion; } } catch { // ignore @@ -587,6 +893,7 @@ async function resolveOpenworkServerBin(options: { explicit?: string; manifest: VersionManifest | null; allowExternal: boolean; + sidecar: SidecarConfig; }): Promise { if (options.explicit && !options.allowExternal) { throw new Error("openwork-server-bin requires --allow-external"); @@ -598,10 +905,6 @@ async function resolveOpenworkServerBin(options: { return { bin: bundled, source: "bundled", expectedVersion }; } - if (!options.allowExternal) { - throw new Error("Bundled openwork-server binary missing. Use --allow-external for dev or rebuild openwrk."); - } - if (options.explicit) { const resolved = resolveBinPath(options.explicit); if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) { @@ -610,6 +913,15 @@ async function resolveOpenworkServerBin(options: { return { bin: resolved, source: "external", expectedVersion }; } + const downloaded = await downloadSidecarBinary({ name: "openwork-server", sidecar: options.sidecar }); + if (downloaded) return downloaded; + + if (!options.allowExternal) { + throw new Error( + "Bundled openwork-server binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.", + ); + } + const require = createRequire(import.meta.url); try { const pkgPath = require.resolve("openwork-server/package.json"); @@ -633,6 +945,7 @@ async function resolveOpencodeBin(options: { explicit?: string; manifest: VersionManifest | null; allowExternal: boolean; + sidecar: SidecarConfig; }): Promise { if (options.explicit && !options.allowExternal) { throw new Error("opencode-bin requires --allow-external"); @@ -644,10 +957,6 @@ async function resolveOpencodeBin(options: { return { bin: bundled, source: "bundled", expectedVersion }; } - if (!options.allowExternal) { - throw new Error("Bundled opencode binary missing. Use --allow-external for dev or rebuild openwrk."); - } - if (options.explicit) { const resolved = resolveBinPath(options.explicit); if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) { @@ -656,6 +965,20 @@ async function resolveOpencodeBin(options: { return { bin: resolved, source: "external", expectedVersion }; } + const downloaded = await downloadSidecarBinary({ name: "opencode", sidecar: options.sidecar }); + if (downloaded) return downloaded; + + const opencodeDownloaded = await resolveOpencodeDownload(options.sidecar, expectedVersion); + if (opencodeDownloaded) { + return { bin: opencodeDownloaded, source: "downloaded", expectedVersion }; + } + + if (!options.allowExternal) { + throw new Error( + "Bundled opencode binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.", + ); + } + return { bin: "opencode", source: "external", expectedVersion }; } @@ -663,6 +986,7 @@ async function resolveOwpenbotBin(options: { explicit?: string; manifest: VersionManifest | null; allowExternal: boolean; + sidecar: SidecarConfig; }): Promise { if (options.explicit && !options.allowExternal) { throw new Error("owpenbot-bin requires --allow-external"); @@ -674,10 +998,6 @@ async function resolveOwpenbotBin(options: { return { bin: bundled, source: "bundled", expectedVersion }; } - if (!options.allowExternal) { - throw new Error("Bundled owpenbot binary missing. Use --allow-external for dev or rebuild openwrk."); - } - if (options.explicit) { const resolved = resolveBinPath(options.explicit); if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) { @@ -686,6 +1006,15 @@ async function resolveOwpenbotBin(options: { return { bin: resolved, source: "external", expectedVersion }; } + const downloaded = await downloadSidecarBinary({ name: "owpenbot", sidecar: options.sidecar }); + if (downloaded) return downloaded; + + if (!options.allowExternal) { + throw new Error( + "Bundled owpenbot binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.", + ); + } + const repoDir = await resolveOwpenbotRepoDir(); if (repoDir) { const binPath = join(repoDir, "dist", "bin", "owpenbot"); @@ -864,6 +1193,9 @@ function printHelp(): void { " --no-owpenbot Disable owpenbot sidecar", " --owpenbot-required Exit if owpenbot stops", " --allow-external Allow external sidecar binaries (dev only, required for custom bins)", + " --sidecar-dir Cache directory for downloaded sidecars", + " --sidecar-base-url Base URL for sidecar downloads", + " --sidecar-manifest Override sidecar manifest URL", " --check Run health checks then exit", " --check-events Verify SSE events during check", " --json Output JSON when applicable", @@ -1487,12 +1819,15 @@ async function runRouterDaemon(args: ParsedArgs) { const opencodeWorkdir = opencodeWorkdirFlag ?? activeWorkspace?.path ?? process.cwd(); const resolvedWorkdir = await ensureWorkspace(opencodeWorkdir); + const cliVersion = await resolveCliVersion(); + const sidecar = resolveSidecarConfig(args.flags, cliVersion); const allowExternal = readBool(args.flags, "allow-external", false, "OPENWRK_ALLOW_EXTERNAL"); const manifest = await readVersionManifest(); const opencodeBinary = await resolveOpencodeBin({ explicit: opencodeBin, manifest, allowExternal, + sidecar, }); let opencodeChild: ReturnType | null = null; @@ -1929,12 +2264,15 @@ async function runStart(args: ParsedArgs) { const corsOrigins = parseList(corsValue); const connectHost = readFlag(args.flags, "connect-host"); + const cliVersion = await resolveCliVersion(); + const sidecar = resolveSidecarConfig(args.flags, cliVersion); const manifest = await readVersionManifest(); const allowExternal = readBool(args.flags, "allow-external", false, "OPENWRK_ALLOW_EXTERNAL"); const opencodeBinary = await resolveOpencodeBin({ explicit: explicitOpencodeBin, manifest, allowExternal, + sidecar, }); const explicitOpenworkServerBin = readFlag(args.flags, "openwork-server-bin") ?? process.env.OPENWORK_SERVER_BIN; const explicitOwpenbotBin = readFlag(args.flags, "owpenbot-bin") ?? process.env.OWPENBOT_BIN; @@ -1944,12 +2282,14 @@ async function runStart(args: ParsedArgs) { explicit: explicitOpenworkServerBin, manifest, allowExternal, + sidecar, }); const owpenbotBinary = owpenbotEnabled ? await resolveOwpenbotBin({ explicit: explicitOwpenbotBin, manifest, allowExternal, + sidecar, }) : null; diff --git a/packages/owpenbot/package.json b/packages/owpenbot/package.json index e61f8ca2..09616836 100644 --- a/packages/owpenbot/package.json +++ b/packages/owpenbot/package.json @@ -30,7 +30,7 @@ "dev": "bun src/cli.ts", "build": "tsc -p tsconfig.json", "build:bin": "bun build --compile src/cli.ts --outfile dist/bin/owpenbot", - "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-windows-x64", + "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-linux-arm64 --target bun-windows-x64", "build:binary": "bun ./script/build.ts --outdir dist/bin", "start": "bun dist/cli.js start", "whatsapp:login": "bun dist/cli.js whatsapp login", diff --git a/packages/server/package.json b/packages/server/package.json index dbf2ce3b..ff4b4688 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,7 +10,7 @@ "dev": "bun src/cli.ts", "build": "tsc -p tsconfig.json", "build:bin": "bun build --compile src/cli.ts --outfile dist/bin/openwork-server", - "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-windows-x64", + "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-linux-arm64 --target bun-windows-x64", "start": "bun dist/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", "prepublishOnly": "pnpm build:bin"