diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c3c2002583..a15d56d4042 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,20 +110,129 @@ jobs: if-no-files-found: error retention-days: 7 + package-standalone: + needs: validate-dispatch + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64 + smoke: true + - os: macos-latest + target: bun-darwin-arm64 + smoke: true + - os: macos-latest + target: bun-darwin-x64 + smoke: false + - os: windows-latest + target: bun-windows-x64 + smoke: true + - os: ubuntu-latest + target: bun-linux-arm64 + smoke: false + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Build standalone binary + run: bun run build:standalone --target ${{ matrix.target }} + + - name: Smoke test standalone binary + if: matrix.smoke && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + binary="dist/standalone/${{ matrix.target }}/ocx" + "$binary" --version + OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & + pid=$! + trap 'kill "$pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do curl -fsS http://127.0.0.1:10177/healthz && break || sleep 1; done + curl -fsS http://127.0.0.1:10177/healthz + test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 + + - name: Smoke test standalone binary (Windows) + if: matrix.smoke && runner.os == 'Windows' + shell: pwsh + run: | + $binary = "dist/standalone/${{ matrix.target }}/ocx.exe" + & $binary --version + $env:OPENCODEX_HOME = Join-Path $env:RUNNER_TEMP "ocx-home" + $process = Start-Process -FilePath $binary -ArgumentList "start", "--port", "10177" -PassThru + try { + for ($i = 0; $i -lt 30; $i++) { + try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Out-Null; break } catch { Start-Sleep -Seconds 1 } + } + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Select-Object -ExpandProperty Content + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null + } finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } + + - name: Archive standalone release + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + STANDALONE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd "dist/standalone/$STANDALONE_TARGET" + if [[ "$RUNNER_OS" == "Windows" ]]; then + powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' + else + tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui + fi + cd ../../.. + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + else sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + fi + + - name: Upload standalone release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: standalone-${{ matrix.target }} + path: | + dist/ocx-*.tar.gz + dist/ocx-*.zip + dist/ocx-*.sha256 + if-no-files-found: error + retention-days: 7 + attach-macos: runs-on: ubuntu-latest - needs: [publish, package-macos] + needs: [publish, package-macos, package-standalone] if: ${{ inputs.dry-run != true }} timeout-minutes: 10 permissions: contents: write steps: - - name: Download the packaged asset + - name: Download the macOS packaged asset uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos-release path: dist/release + - name: Download standalone packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: standalone-* + merge-multiple: true + path: dist/release + - name: Verify the checksum before uploading run: | cd dist/release diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index c5a7d36578e..9ff7e5a72c6 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -49,6 +49,19 @@ ocx --version opencodex --version ``` +## Standalone binary (no npm) + +Release downloads also include a standalone `ocx` binary for supported macOS, Linux, and Windows +targets. It includes the Bun runtime and dashboard, so npm, Node, and a separate Bun installation +are not required. Download the archive for your platform, extract it, and run: + +```bash +./ocx --version +./ocx start +``` + +The extracted `gui/dist` directory must stay beside the binary so `GET /` can serve the dashboard. + ### Release channels The stable `latest` channel already includes GPT-5.6 Sol/Terra/Luna catalog support for ChatGPT, diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 042eb183045..7734c9f2761 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Configure your first provider and route OpenAI Codex through openco This guide takes you from a fresh install to running Codex against a non-OpenAI model. +## Standalone binary (no npm) + +You can also use a release archive containing the `ocx` binary and Bun runtime without npm. +Extract it with its `gui/dist` directory beside the binary, then run `./ocx start`. + ## 1. Run the setup wizard ```bash diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index a62daede0a6..49b50f7d37d 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## スタンドアロンバイナリ(npm 不要) + +リリースには、対応する macOS、Linux、Windows 向けのスタンドアロン `ocx` バイナリも含まれます。 +Bun ランタイムとダッシュボードが含まれるため、npm、Node、別途の Bun インストールは必要ありません。 +お使いの環境向けのアーカイブをダウンロードして展開し、次のように実行します。 + +```bash +./ocx --version +./ocx start +``` + +ダッシュボードを提供するため、展開した `gui/dist` ディレクトリはバイナリの隣に置いたままにしてください。 + ### 配布チャネル 安定チャネルの `latest` にも ChatGPT、OpenAI API キー、OpenRouter、実験段階の Cursor 経路のための diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index f9184dfc5f6..9c2d25a93e4 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 最初のプロバイダーを構成し、3 つのコマンドで O このガイドでは、新規インストールから非 OpenAI モデルに対して Codex を実行するまでを説明します。 +## スタンドアロンバイナリ(npm 不要) + +npm を使わず、Bun ランタイムを含むリリースアーカイブの `ocx` バイナリも利用できます。 +`gui/dist` ディレクトリをバイナリの隣に置いて展開し、`./ocx start` を実行してください。 + ## 1. セットアップウィザードを実行します ```bash diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 70a62f86099..146f25bb01c 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## 독립 실행형 바이너리(npm 없음) + +릴리스에는 지원되는 macOS, Linux, Windows용 독립 실행형 `ocx` 바이너리도 포함됩니다. +Bun 런타임과 대시보드가 포함되어 있으므로 npm, Node 또는 별도의 Bun 설치가 필요하지 않습니다. +플랫폼에 맞는 아카이브를 다운로드해 압축을 풀고 다음과 같이 실행하세요. + +```bash +./ocx --version +./ocx start +``` + +대시보드를 제공하려면 압축을 푼 `gui/dist` 디렉터리를 바이너리 옆에 그대로 두어야 합니다. + ### 배포 채널 안정화 채널인 `latest`에도 ChatGPT, OpenAI API 키, OpenRouter, 실험 단계의 Cursor 경로를 위한 diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index f1a179649b7..7e92511d56a 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 첫 프로바이더를 설정하고 명령어 세 개로 OpenAI Cod 이 가이드는 새로 설치한 상태에서 OpenAI가 아닌 모델로 Codex를 실행하기까지의 과정을 안내합니다. +## 독립 실행형 바이너리(npm 없음) + +npm 없이 Bun 런타임이 포함된 릴리스 아카이브의 `ocx` 바이너리를 사용할 수도 있습니다. +`gui/dist` 디렉터리를 바이너리 옆에 둔 채 압축을 풀고 `./ocx start`를 실행하세요. + ## 1. 설정 마법사 실행 ```bash diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index a1f3724a4ba..ca0fe20b3e4 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -45,6 +45,19 @@ ocx --version opencodex --version ``` +## Автономный бинарный файл (без npm) + +В релиз входят автономные бинарные файлы `ocx` для поддерживаемых macOS, Linux и Windows. +Они содержат рантайм Bun и дашборд, поэтому npm, Node и отдельная установка Bun не нужны. +Скачайте архив для своей платформы, распакуйте его и выполните: + +```bash +./ocx --version +./ocx start +``` + +Чтобы дашборд был доступен, оставьте распакованный каталог `gui/dist` рядом с бинарным файлом. + ### Каналы релизов Стабильный канал `latest` уже включает поддержку каталога GPT-5.6 Sol/Terra/Luna для маршрутов diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 1b086c7db2c..088843f0f96 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Настройте первого провайдера и напр Это руководство проводит от чистой установки до запуска Codex с моделью не от OpenAI. +## Автономный бинарный файл (без npm) + +Можно также использовать архив с бинарным файлом `ocx` и рантаймом Bun без npm. +Распакуйте его, оставив каталог `gui/dist` рядом с бинарным файлом, и выполните `./ocx start`. + ## 1. Запустите мастер настройки ```bash diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index eb5b02ec948..335debd845d 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -42,6 +42,18 @@ ocx --version opencodex --version ``` +## 独立二进制文件(无需 npm) + +发布包还包含适用于 macOS、Linux 和 Windows 的独立 `ocx` 二进制文件。 +它内置 Bun 运行时和仪表盘,因此无需安装 npm、Node 或单独的 Bun。下载适合你平台的压缩包,解压后运行: + +```bash +./ocx --version +./ocx start +``` + +为了让仪表盘可用,请将解压后的 `gui/dist` 目录保留在二进制文件旁边。 + ### 发布渠道 稳定的 `latest` 渠道已经包含 ChatGPT、OpenAI API key、OpenRouter 以及实验性 Cursor 路由所需的 diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index e965b159a6b..91ac4ba047b 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 配置你的第一个 provider,并在三条命令内让 OpenAI Co 本指南将带你从全新安装,一路走到用一个非 OpenAI 模型运行 Codex。 +## 独立二进制文件(无需 npm) + +你也可以使用包含 Bun 运行时的发布压缩包中的 `ocx`,无需 npm。 +解压时将 `gui/dist` 目录保留在二进制文件旁边,然后运行 `./ocx start`。 + ## 1. 运行设置向导 ```bash diff --git a/package.json b/package.json index 26fce207d75..4b6912dfa6f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "build:macos": "bash scripts/build-macos-app.sh", + "build:standalone": "bun scripts/build-standalone.ts", "package:macos": "bash scripts/package-macos-release.sh", "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests", "prepare:package": "bun scripts/prepare-package.ts", diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts new file mode 100644 index 00000000000..7b71df0bc43 --- /dev/null +++ b/scripts/build-standalone.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const targets = new Set([ + "bun-darwin-arm64", + "bun-darwin-x64", + "bun-windows-x64", + "bun-linux-x64", + "bun-linux-arm64", +]); + +function hostTarget(): string { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + return `bun-${platform}-${arch}`; +} + +function argumentValue(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index >= 0 ? Bun.argv[index + 1] : undefined; +} + +const target = argumentValue("--target") ?? hostTarget(); +if (!targets.has(target)) { + throw new Error(`Unsupported standalone target: ${target}`); +} + +const repoRoot = resolve(import.meta.dir, ".."); +const guiDist = join(repoRoot, "gui", "dist"); +if (!existsSync(join(guiDist, "index.html"))) { + throw new Error("gui/dist is missing; run `bun run build:gui` first"); +} + +const output = resolve(argumentValue("--out") ?? join(repoRoot, "dist", "standalone", target)); +mkdirSync(output, { recursive: true }); +const executable = join(output, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +const result = Bun.spawnSync([ + process.execPath, + "build", + "--compile", + "--target", + target, + join(repoRoot, "src", "cli", "index.ts"), + "--outfile", + executable, +], { stdout: "inherit", stderr: "inherit" }); +if (result.exitCode !== 0) process.exit(result.exitCode); + +cpSync(guiDist, join(output, "gui", "dist"), { recursive: true }); +const digest = createHash("sha256").update(readFileSync(executable)).digest("hex"); +writeFileSync(join(output, "SHA256SUMS"), `${digest} ${executable.split(/[\\/]/).pop()}\n`); +console.log(`Built ${executable}`); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f1e6f40d998..3a27b56cc51 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,9 @@ }, "explicit": { "macos-build-script.test.ts": "gui", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", diff --git a/src/cli/help.ts b/src/cli/help.ts index da5450880b4..38a5c18da19 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,9 +1,5 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { findCommand } from "./registry"; - -const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); +import { packageVersion as readPackageVersion } from "../lib/package-version"; /** * Version of the `ocx` bundle this process is running from. @@ -13,9 +9,7 @@ const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta * rather than throwing; callers must treat that as "cannot compare", not as a mismatch. */ export function packageVersion(): string { - const raw = readFileSync(join(repoRoot, "package.json"), "utf8"); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === "string" ? parsed.version : "unknown"; + return readPackageVersion(); } export function printVersion(): void { diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index f0dd56b7656..ef8c4f43fae 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import type { Server } from "bun"; import { loadConfig } from "../config"; import { browserSecurityHeaders } from "../server/auth-cors"; @@ -16,11 +15,9 @@ import { readClientConnectionState } from "./state"; import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api"; import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth"; import { relayHubManagementRequest } from "./hub-relay"; +import { packageVersion } from "../lib/package-version"; -const VERSION = (() => { - try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; } - catch { return "0.0.0"; } -})(); +const VERSION = packageVersion("0.0.0"); const GUI_SPA_PATHS = new Set([ "/dashboard", "/startup", "/providers", "/models", "/subagents", "/logs", "/usage", "/storage", "/codex-set", "/integrations", diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index e5c7ed31d34..ffe5e0b53f2 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -14,6 +14,7 @@ import { createRequire } from "node:module"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { isRealBunBinary } from "./bun-binary-validator.mjs"; +import { isStandaloneBinary } from "./standalone"; export { isRealBunBinary }; @@ -38,10 +39,10 @@ export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; */ export const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; -export type BunRuntimeSource = "override" | "bundled" | "process"; +export type BunRuntimeSource = "override" | "bundled" | "process" | "standalone"; /** The only provenance values any surface may accept off the wire or out of the env. */ -export const BUN_RUNTIME_SOURCES: readonly BunRuntimeSource[] = ["override", "bundled", "process"]; +export const BUN_RUNTIME_SOURCES: readonly BunRuntimeSource[] = ["override", "bundled", "process", "standalone"]; export type DurableBunRuntime = { path: string; @@ -165,6 +166,9 @@ function unmarkedDurableBunRuntime(): DurableBunRuntime { } export function durableBunRuntime(): DurableBunRuntime { + if (isStandaloneBinary()) { + return { path: process.execPath, source: "standalone", overrideEnv: BUN_OVERRIDE_ENV }; + } // A durable artifact must use the runtime selected BEFORE Bun auto-loaded a // project dotenv. The Node launcher and owned service/shim launchers stamp the // selected source/path pair; it is accepted only when it names this exact diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 9b7d1e8429f..30372de183e 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -1,4 +1,5 @@ import { statSync } from "node:fs"; +import { isStandaloneBinary } from "./standalone"; export interface PackageTreeObservation { readonly device: bigint; @@ -96,6 +97,6 @@ export function createRuntimePackageTreeIntegrityGuard( observe: ObservePackageTree = observePackageManifest, now: () => number = Date.now, ): PackageTreeIntegrityGuard { - if (installer === "source") return { status: () => ({ ok: true }) }; + if (installer === "source" || isStandaloneBinary()) return { status: () => ({ ok: true }) }; return createPackageTreeIntegrityGuard(observe, now); } diff --git a/src/lib/package-version.ts b/src/lib/package-version.ts new file mode 100644 index 00000000000..100f7e9bc0d --- /dev/null +++ b/src/lib/package-version.ts @@ -0,0 +1,8 @@ +import pkg from "../../package.json" with { type: "json" }; + +type PackageManifest = { version?: unknown }; + +export function packageVersion(fallback = "unknown"): string { + const version = (pkg as PackageManifest).version; + return typeof version === "string" ? version : fallback; +} diff --git a/src/lib/standalone.ts b/src/lib/standalone.ts new file mode 100644 index 00000000000..72cc9f36183 --- /dev/null +++ b/src/lib/standalone.ts @@ -0,0 +1,16 @@ +import { realpathSync } from "node:fs"; +import { dirname } from "node:path"; + +/** Compiled Bun binaries expose their bundled module tree through the `$bunfs` marker. */ +export function isStandaloneBinary(): boolean { + return isStandaloneModuleUrl(import.meta.url); +} + +export function isStandaloneModuleUrl(url: string): boolean { + return url.includes("/$bunfs/") || /^file:\/\/\/[A-Za-z]:\/~BUN\//.test(url); +} + +/** Directory containing the compiled executable and its copied runtime assets. */ +export function standaloneRoot(): string { + return dirname(realpathSync(process.execPath)); +} diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index f1dc60ffbae..321e3ea8132 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -72,7 +72,7 @@ export interface WinswEntry { bun: string; /** Provenance of `bun`, resolved together with it so the two can never disagree. */ bunRuntimeSource: BunRuntimeSource; - cli: string; + cli: string | null; } /** @@ -118,7 +118,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces OpenCodex Proxy (native) OpenCodex proxy running as a native Windows service (windowless, starts at boot). ${xmlEscape(entry.bun)} - ${xmlEscape(`"${entry.cli}" start --port ${safeListenPort}`)} + ${xmlEscape(`${entry.cli ? `"${entry.cli}" ` : ""}start --port ${safeListenPort}`)} ${envLines.join("\n")} ${xmlEscape(winswLogDir())} diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index d128361c02b..9a6439f0971 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -2,15 +2,11 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; import { browserSecurityHeaders } from "./auth-cors"; import type { GuiSessionBootstrap } from "./gui-session"; +import { packageVersion } from "../lib/package-version"; +import { isStandaloneBinary, standaloneRoot } from "../lib/standalone"; /** opencodex version, read from the packaged package.json (same source as the server bootstrap). */ -const VERSION = (() => { - try { - return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; - } catch { - return "0.0.0"; - } -})(); +const VERSION = packageVersion("0.0.0"); const MIME_TYPES: Record = { ".html": "text/html", ".js": "application/javascript", ".css": "text/css", @@ -26,9 +22,11 @@ const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/; function findGuiDist(): string | null { const candidates = [ + process.env.OPENCODEX_GUI_DIST, + ...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []), join(import.meta.dir, "..", "..", "gui", "dist"), join(import.meta.dir, "..", "..", "..", "gui", "dist"), - ]; + ].filter((candidate): candidate is string => Boolean(candidate)); for (const c of candidates) { if (existsSync(join(c, "index.html"))) return c; } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index dbd5d7345d6..63733717d9b 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1,6 +1,5 @@ import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation"; import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; import type { CatalogModel } from "../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../codex/catalog"; import { @@ -86,15 +85,11 @@ import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-typ import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status"; import { managementBodyTooLargeResponse } from "./management/body"; import { handleSessionRoutes } from "./management/session-routes"; +import { packageVersion } from "../lib/package-version"; // installed npm version instead of a stale hardcode. -export const VERSION = (() => { - try { - return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; - } catch { - return "0.0.0"; - } -})(); +const MANAGEMENT_VERSION_FALLBACK = "0.0.0"; +export const VERSION = packageVersion(MANAGEMENT_VERSION_FALLBACK); const managementConvergenceBindings = new WeakMap) => ConvergeCodex; diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 35764c83522..abc99ff66ae 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -4,7 +4,7 @@ import { UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, UPSTREAM_NO_RESPONSE_CODE, } from "../../lib/upstream-retry"; -import { readFileSync } from "node:fs"; +import { packageVersion } from "../../lib/package-version"; // If the 101 never arrives (network black hole), give SSE a chance well before // the caller's connect timeout (default 200s) would fire. export const UPGRADE_DEADLINE_MS = 10_000; @@ -72,13 +72,7 @@ export function markCodexWsResponse(response: Response, observed: boolean): void * importing management-api from the transport layer would invert the * layering and pull the management surface into every WS exchange. */ -const OCX_VERSION = (() => { - try { - return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string; - } catch { - return "0.0.0"; - } -})(); +const OCX_VERSION = packageVersion("0.0.0"); /** * The durable form of the stage counters, carried out of the exchange on the diff --git a/src/service/health.ts b/src/service/health.ts index 0306ab536d4..d04c83ef3ff 100644 --- a/src/service/health.ts +++ b/src/service/health.ts @@ -32,9 +32,10 @@ export function resolveServiceListenPort(override?: number): number { return 10100; } -export function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string { +export function buildServiceShellCommand(bun: string, cli: string | null, port = resolveServiceListenPort()): string { const tokenFile = serviceApiTokenFilePath(); - return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`; + const args = cli ? `${shellQuote(cli)} start` : "start"; + return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${args} --port ${port}`; } /** diff --git a/src/service/launchd.ts b/src/service/launchd.ts index 613c40ea530..d561d01a294 100644 --- a/src/service/launchd.ts +++ b/src/service/launchd.ts @@ -160,7 +160,7 @@ function launchdServiceCommand( */ export function expectedLaunchdCommand( port: number, - deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {}, + deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string | null } } = {}, ): string { const state = deps.state === undefined ? readServiceInstallState() : deps.state; if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port); diff --git a/src/service/state.ts b/src/service/state.ts index 415c3b7d761..2de6d6ea1f8 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -9,6 +9,7 @@ import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; import { hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard"; +import { isStandaloneBinary } from "../lib/standalone"; /** * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in @@ -26,14 +27,18 @@ export const serviceSourceDir = dirname(import.meta.dir); export type ServiceBackend = "scheduler" | "native"; -export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { +export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string | null } { // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. // // Path and provenance come from ONE resolution so the marker can never describe a // different binary than the one actually baked. - return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(serviceSourceDir, "cli", "index.ts") }; + return { + bun: runtime.path, + bunRuntimeSource: runtime.source, + cli: runtime.source === "standalone" || isStandaloneBinary() ? null : join(serviceSourceDir, "cli", "index.ts"), + }; } /** @@ -223,7 +228,7 @@ export interface ServiceInstallState { codexSqliteHome?: string; /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */ bunPath?: string; - cliPath?: string; + cliPath?: string | null; /** * launchd and systemd. The stable `ocx` launcher the service definition actually invokes, * when one was found. Present means `bunPath`/`cliPath` are provenance for the install, diff --git a/src/service/windows-taskxml.ts b/src/service/windows-taskxml.ts index e3ede372d21..38d8286d5b9 100644 --- a/src/service/windows-taskxml.ts +++ b/src/service/windows-taskxml.ts @@ -79,10 +79,11 @@ export function buildWindowsServiceScript( windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), windowsBatchSet("OCX_BUN", bun, "path"), - windowsBatchSet("OCX_CLI", cli, "path"), + windowsBatchSet("OCX_CLI", cli ?? undefined, "path"), + // Standalone executables have no npm package tree; recovery is "replace the executable", so no OCX_PKG_DIR/restore_backup wiring. // Package root for the transactional-update restore path (#1942): cli is // \src\cli\index.ts, so the package dir is three levels up. - 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"', + cli ? 'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"' : null, 'if exist "%OCX_API_TOKEN_FILE%" (', ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"', ")", @@ -101,14 +102,14 @@ export function buildWindowsServiceScript( ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair', " exit /b 3", ")", - 'if not exist "%OCX_CLI%" (', - " call :restore_backup", - ")", - 'if not exist "%OCX_CLI%" (', - ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair', - " exit /b 3", - ")", - `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`, + cli ? 'if not exist "%OCX_CLI%" (' : null, + cli ? " call :restore_backup" : null, + cli ? ")" : null, + cli ? 'if not exist "%OCX_CLI%" (' : null, + cli ? ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair' : null, + cli ? " exit /b 3" : null, + cli ? ")" : null, + cli ? `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1` : `"%OCX_BUN%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`, "if %ERRORLEVEL% NEQ 0 (", ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s', // `timeout` needs console stdin and dies with "Input redirection is not supported" diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 index 8ebb9c05380..e2ee8727c7c 100644 --- a/src/tray/windows-tray.ps1 +++ b/src/tray/windows-tray.ps1 @@ -5,7 +5,7 @@ param( [Parameter(Mandatory = $true)][string]$OpenCodexHome, # Provenance of $BunPath, chosen when the tray entry was built. Optional so an # already-installed launcher command from an older version still starts. - [ValidateSet("", "override", "bundled", "process")][string]$BunRuntimeSource = "", + [ValidateSet("", "override", "bundled", "process", "standalone")][string]$BunRuntimeSource = "", [ValidateSet("Run", "Stop")][string]$Mode = "Run", [int]$HostPid = 0 ) diff --git a/src/update/index.ts b/src/update/index.ts index 2197ef0f34b..053f48dc22a 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -26,6 +26,7 @@ import { } from "./npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { packageVersion } from "../lib/package-version"; import { selfLaunchArgv } from "../lib/self-launch-argv"; /** @@ -143,11 +144,7 @@ export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null } export function currentVersion(): string { - try { - return (JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")).version as string) ?? "?"; - } catch { - return "?"; - } + return packageVersion("?"); } export function defaultUpdateTag(current: string): Channel { diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a5faae7d44b..ca2ab2e8614 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -753,3 +753,8 @@ is chosen; for a combo selector the warning lists the combo's target providers f and states that failover targets receive the conversation too. `GET /api/settings` returns the override or null; `PUT /api/settings` accepts a complete validated object or null to clear it. Save failure restores live settings and deletion provenance; the dashboard retains the draft for retry. + +`src/server/gui-static.ts` serves the dashboard from `gui/dist`, with `OPENCODEX_GUI_DIST` taking +priority and standalone binaries resolving the copied directory beside `ocx`. Runtime package +metadata comes from the bundled `src/lib/package-version.ts` manifest import so compiled binaries +do not read a source-tree `package.json`. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 89fe8db595a..1d58ace1426 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -256,6 +256,10 @@ typecheck and GUI build. `scripts/release.ts` accepts either an explicit version `bun run privacy:scan` before the version bump, commit/push, Cross-platform CI wait, and GitHub Release workflow dispatch. Docs publishing is separate from npm release publishing. +The `package-standalone` job in `.github/workflows/release.yml` also builds Bun compiled +`ocx` archives for Linux, macOS, and Windows, bundles `gui/dist`, smoke-tests `/healthz`, and +publishes SHA-256 sidecars for the attach job. + Opening a release starts with the `dev` pre-move. Dispatch `.github/workflows/dev-version-bump.yml` with the intended version, merge the pull request it opens, then promote and release. A no-op is valid when `dev` already outranks the target. `release.yml` diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index d293a286dfb..527702612aa 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -198,3 +198,8 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows The service loads the optional `compactionRouting` block from persisted configuration. [Responses ingress](../transports/responses.md#compaction-routing-overrides) applies it to individual compaction requests whose trigger the block names. + +Standalone binaries use `src/lib/standalone.ts` to detect the Bun `$bunfs` runtime and +`src/service/state.ts` to compose durable service commands as ` start`, without a +source-tree CLI path. The copied `gui/dist` directory is located by `src/server/gui-static.ts`; +`OPENCODEX_GUI_DIST` remains an explicit override. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 494d9326de3..25c13f1e48a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,8 @@ { "macos-build-script.test.ts": "gui", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", diff --git a/tests/gui/gui-static.test.ts b/tests/gui/gui-static.test.ts index 43c8c1887e9..b8808f9af7d 100644 --- a/tests/gui/gui-static.test.ts +++ b/tests/gui/gui-static.test.ts @@ -6,11 +6,24 @@ import { serveGuiFile } from "../../src/server/gui-static"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const temporaryDirectories: string[] = []; +const previousGuiDist = process.env.OPENCODEX_GUI_DIST; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { removeTreeWithRetry(directory); } + if (previousGuiDist === undefined) delete process.env.OPENCODEX_GUI_DIST; + else process.env.OPENCODEX_GUI_DIST = previousGuiDist; +}); + +test("serves the dashboard from OPENCODEX_GUI_DIST when no explicit root is supplied", async () => { + const guiDist = mkdtempSync(join(tmpdir(), "ocx-gui-static-override-")); + temporaryDirectories.push(guiDist); + writeFileSync(join(guiDist, "index.html"), "standalone"); + process.env.OPENCODEX_GUI_DIST = guiDist; + const response = serveGuiFile("/"); + expect(response).not.toBeNull(); + expect(await response!.text()).toContain("standalone"); }); test("#2792 snapshots a static asset before server framing can outlive the file", async () => { diff --git a/tests/gui/standalone-build-script.test.ts b/tests/gui/standalone-build-script.test.ts new file mode 100644 index 00000000000..8e524ec704f --- /dev/null +++ b/tests/gui/standalone-build-script.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +const script = await Bun.file(repoPath("scripts", "build-standalone.ts")).text(); + +test("standalone build script exposes supported targets and packaging contract", () => { + for (const target of [ + "bun-darwin-arm64", + "bun-darwin-x64", + "bun-windows-x64", + "bun-linux-x64", + "bun-linux-arm64", + ]) expect(script).toContain(target); + expect(script).toContain("--compile"); + expect(script).toContain("--outfile"); + expect(script).toContain("gui/dist"); + expect(script).toContain("SHA256SUMS"); +}); diff --git a/tests/lib/standalone.test.ts b/tests/lib/standalone.test.ts new file mode 100644 index 00000000000..d58c3a7aa0d --- /dev/null +++ b/tests/lib/standalone.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { dirname } from "node:path"; +import { realpathSync } from "node:fs"; +import { + isStandaloneBinary, + isStandaloneModuleUrl, + standaloneRoot, +} from "../../src/lib/standalone"; + +test("source Bun processes are not identified as compiled binaries", () => { + expect(isStandaloneBinary()).toBe(false); +}); + +test("compiled module URL markers are recognized on POSIX and Windows", () => { + expect(isStandaloneModuleUrl("file:///$bunfs/root/src/cli/index.ts")).toBe(true); + expect(isStandaloneModuleUrl("file:///B:/~BUN/root/src/cli/index.ts")).toBe(true); + expect(isStandaloneModuleUrl("file:///Users/x/src/lib/standalone.ts")).toBe(false); +}); + +test("standaloneRoot follows the running executable", () => { + expect(standaloneRoot()).toBe(dirname(realpathSync(process.execPath))); +}); diff --git a/tests/server/memory-watchdog.test.ts b/tests/server/memory-watchdog.test.ts index 918503456ce..4ddb6dafdd2 100644 --- a/tests/server/memory-watchdog.test.ts +++ b/tests/server/memory-watchdog.test.ts @@ -11,6 +11,7 @@ import { } from "../../src/server/memory-watchdog"; import { handleManagementAPI } from "../../src/server/management-api"; import { selectEagerPath } from "../../src/lib/bun-stream-caps"; +import { reportedBunRuntimeSource } from "../../src/lib/bun-runtime"; import type { OcxConfig } from "../../src/types"; import { appOwnedBytesSnapshot, @@ -300,36 +301,42 @@ describe("GET /api/system/memory", () => { return await res!.json() as { bunRuntimeSource?: unknown; bunRevision?: unknown }; }; try { - for (const source of ["override", "bundled", "process"]) { + for (const source of ["override", "bundled", "process", "standalone"]) { process.env.OCX_BUN_RUNTIME_SOURCE = source; // Source alone is not enough: the marker must name THIS executable. - expect((await read()).bunRuntimeSource).toBeUndefined(); + expect(reportedBunRuntimeSource()).toBeUndefined(); process.env.OCX_BUN_RUNTIME_PATH = process.execPath; - expect((await read()).bunRuntimeSource).toBe(source); + expect(reportedBunRuntimeSource()).toBe(source); delete process.env.OCX_BUN_RUNTIME_PATH; } // A mismatched recorded path describes another binary — stay absent. process.env.OCX_BUN_RUNTIME_SOURCE = "override"; process.env.OCX_BUN_RUNTIME_PATH = "/usr/local/bin/definitely-not-this-bun"; - expect((await read()).bunRuntimeSource).toBeUndefined(); + expect(reportedBunRuntimeSource()).toBeUndefined(); + + process.env.OCX_BUN_RUNTIME_SOURCE = "system"; delete process.env.OCX_BUN_RUNTIME_PATH; - delete process.env.OCX_BUN_RUNTIME_SOURCE; + expect(reportedBunRuntimeSource()).toBeUndefined(); + + process.env.OCX_BUN_RUNTIME_SOURCE = "override"; + process.env.OCX_BUN_RUNTIME_PATH = process.execPath; + expect((await read()).bunRuntimeSource).toBe("override"); + // An unset or unrecognized marker must leave the field absent rather than // shipping a value doctor would then have to distrust. + delete process.env.OCX_BUN_RUNTIME_PATH; + delete process.env.OCX_BUN_RUNTIME_SOURCE; const unset = await read(); expect(unset.bunRuntimeSource).toBeUndefined(); expect(typeof unset.bunRevision).toBe("string"); - - process.env.OCX_BUN_RUNTIME_SOURCE = "system"; - expect((await read()).bunRuntimeSource).toBeUndefined(); } finally { if (inherited === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; else process.env.OCX_BUN_RUNTIME_SOURCE = inherited; delete process.env.OCX_BUN_RUNTIME_PATH; } - // The route costs ~600 ms per read on the shared CI runners, and this test makes - // eight of them — marginally over bun's 5 s default on a loaded box. - }, 20_000); + // Two route reads only: each one walks the JSC heap via heapStats(), which is what made nine of them exceed the budget on loaded macOS runners; the env matrix is covered on the pure function above. + // Even two reads have taken ~28s on a saturated macOS shard, so the budget is generous. + }, 60_000); test("GET system memory includes privacy-safe appOwnedBytes scalars", async () => { registerDefaultAppOwnedMemoryStores(); diff --git a/tests/service/standalone-service.test.ts b/tests/service/standalone-service.test.ts new file mode 100644 index 00000000000..3f29882a0da --- /dev/null +++ b/tests/service/standalone-service.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { buildServiceShellCommand } from "../../src/service/health"; +import { buildWinswXml } from "../../src/lib/winsw"; +import { cliEntry } from "../../src/service/state"; +import { buildWindowsServiceScript } from "../../src/service/windows-taskxml"; + +const runtime = { + path: "/opt/opencodex/ocx", + source: "standalone" as const, + overrideEnv: "OPENCODEX_BUN_PATH" as const, +}; + +test("standalone service entries invoke the executable without a source CLI", () => { + const entry = cliEntry(runtime); + expect(entry).toEqual({ bun: runtime.path, bunRuntimeSource: "standalone", cli: null }); + expect(buildServiceShellCommand(entry.bun, entry.cli, 10177)).toContain( + "exec '/opt/opencodex/ocx' start --port 10177", + ); + expect(buildWindowsServiceScript(entry, 10177)).toContain( + '"%OCX_BUN%" start --port 10177', + ); + expect(buildWindowsServiceScript(entry, 10177)).not.toContain('"%OCX_BUN%" "%OCX_CLI%" start'); + expect(buildWinswXml(entry, { OCX_BAKE_PORT: "10177" })).toContain( + "start --port 10177", + ); +});