feat: Bun single-file executable distribution (#819)

* feat: add Bun single-file executable distribution

- Add 7 platform packages for standalone CLI binaries
- Add bin/platform.js for shared platform detection
- Add bin/oh-my-opencode.js ESM wrapper
- Add postinstall.mjs for binary verification
- Add script/build-binaries.ts for cross-compilation
- Update publish workflow for multi-package publishing
- Add CI guard against @ast-grep/napi in CLI
- Add unit tests for platform detection (12 tests)
- Update README to remove Bun runtime requirement

Platforms supported:
- macOS ARM64 & x64
- Linux x64 & ARM64 (glibc)
- Linux x64 & ARM64 (musl/Alpine)
- Windows x64

Closes #816

* chore: remove unnecessary @ast-grep/napi CI check

* chore: gitignore compiled platform binaries

* fix: use require() instead of top-level await import() for Bun compile compatibility

* refactor: use static ESM import for package.json instead of require()
This commit is contained in:
Kenny 2026-01-15 10:33:07 -05:00 committed by GitHub
parent 72a3975799
commit c67ca8275e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 695 additions and 30 deletions

View File

@ -60,7 +60,7 @@ jobs:
run: bun run typecheck run: bun run typecheck
publish: publish:
runs-on: ubuntu-latest runs-on: macos-latest
needs: [test, typecheck] needs: [test, typecheck]
if: github.repository == 'code-yeongyu/oh-my-opencode' if: github.repository == 'code-yeongyu/oh-my-opencode'
steps: steps:
@ -113,6 +113,9 @@ jobs:
echo "=== Running build:schema ===" echo "=== Running build:schema ==="
bun run build:schema bun run build:schema
- name: Build platform binaries
run: bun run build:binaries
- name: Verify build output - name: Verify build output
run: | run: |
echo "=== dist/ contents ===" echo "=== dist/ contents ==="
@ -121,6 +124,13 @@ jobs:
ls -la dist/cli/ ls -la dist/cli/
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1) test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1) test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
echo "=== Platform binaries ==="
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl; do
test -f "packages/${platform}/bin/oh-my-opencode" || (echo "ERROR: packages/${platform}/bin/oh-my-opencode not found!" && exit 1)
echo "✓ packages/${platform}/bin/oh-my-opencode"
done
test -f "packages/windows-x64/bin/oh-my-opencode.exe" || (echo "ERROR: packages/windows-x64/bin/oh-my-opencode.exe not found!" && exit 1)
echo "✓ packages/windows-x64/bin/oh-my-opencode.exe"
- name: Publish - name: Publish
run: bun run script/publish.ts run: bun run script/publish.ts

4
.gitignore vendored
View File

@ -5,6 +5,10 @@ node_modules/
# Build output # Build output
dist/ dist/
# Platform binaries (built, not committed)
packages/*/bin/oh-my-opencode
packages/*/bin/oh-my-opencode.exe
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/

View File

@ -258,20 +258,17 @@ If you don't want all this, as mentioned, you can just pick and choose specific
### For Humans ### For Humans
> **⚠️ Prerequisite: Bun is required**
>
> This tool **requires [Bun](https://bun.sh/) to be installed** on your system.
> Even if you use `npx` to run the installer, the underlying runtime depends on Bun.
Run the interactive installer: Run the interactive installer:
```bash ```bash
bunx oh-my-opencode install
# or use npx if bunx doesn't work
npx oh-my-opencode install npx oh-my-opencode install
# or with bun
bunx oh-my-opencode install
``` ```
> **Note for Ubuntu/Debian users**: If you installed Bun via Snap (`/snap/bin/bun`), `bunx` will fail with "script not found" due to Snap's sandboxing. Either use `npx` instead, or reinstall Bun via the official installer: `curl -fsSL https://bun.sh/install | bash` > **Note**: The CLI ships with standalone binaries for all major platforms. No runtime (Bun/Node.js) is required for CLI execution after installation.
>
> **Supported platforms**: macOS (ARM64, x64), Linux (x64, ARM64, Alpine/musl), Windows (x64)
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed. Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.

80
bin/oh-my-opencode.js Normal file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env node
// bin/oh-my-opencode.js
// Wrapper script that detects platform and spawns the correct binary
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
const require = createRequire(import.meta.url);
/**
* Detect libc family on Linux
* @returns {string | null} 'glibc', 'musl', or null if detection fails
*/
function getLibcFamily() {
if (process.platform !== "linux") {
return undefined; // Not needed on non-Linux
}
try {
const detectLibc = require("detect-libc");
return detectLibc.familySync();
} catch {
// detect-libc not available
return null;
}
}
function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
// Get platform package name
let pkg;
try {
pkg = getPlatformPackage({ platform, arch, libcFamily });
} catch (error) {
console.error(`\noh-my-opencode: ${error.message}\n`);
process.exit(1);
}
// Resolve binary path
const binRelPath = getBinaryPath(pkg, platform);
let binPath;
try {
binPath = require.resolve(binRelPath);
} catch {
console.error(`\noh-my-opencode: Platform binary not installed.`);
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
console.error(`Expected package: ${pkg}`);
console.error(`\nTo fix, run:`);
console.error(` npm install ${pkg}\n`);
process.exit(1);
}
// Spawn the binary
const result = spawnSync(binPath, process.argv.slice(2), {
stdio: "inherit",
});
// Handle spawn errors
if (result.error) {
console.error(`\noh-my-opencode: Failed to execute binary.`);
console.error(`Error: ${result.error.message}\n`);
process.exit(2);
}
// Handle signals
if (result.signal) {
const signalNum = result.signal === "SIGTERM" ? 15 :
result.signal === "SIGKILL" ? 9 :
result.signal === "SIGINT" ? 2 : 1;
process.exit(128 + signalNum);
}
process.exit(result.status ?? 1);
}
main();

38
bin/platform.js Normal file
View File

@ -0,0 +1,38 @@
// bin/platform.js
// Shared platform detection module - used by wrapper and postinstall
/**
* Get the platform-specific package name
* @param {{ platform: string, arch: string, libcFamily?: string | null }} options
* @returns {string} Package name like "oh-my-opencode-darwin-arm64"
* @throws {Error} If libc cannot be detected on Linux
*/
export function getPlatformPackage({ platform, arch, libcFamily }) {
let suffix = "";
if (platform === "linux") {
if (libcFamily === null || libcFamily === undefined) {
throw new Error(
"Could not detect libc on Linux. " +
"Please ensure detect-libc is installed or report this issue."
);
}
if (libcFamily === "musl") {
suffix = "-musl";
}
}
// Map platform names: win32 -> windows (for package name)
const os = platform === "win32" ? "windows" : platform;
return `oh-my-opencode-${os}-${arch}${suffix}`;
}
/**
* Get the path to the binary within a platform package
* @param {string} pkg Package name
* @param {string} platform Process platform
* @returns {string} Relative path like "oh-my-opencode-darwin-arm64/bin/oh-my-opencode"
*/
export function getBinaryPath(pkg, platform) {
const ext = platform === "win32" ? ".exe" : "";
return `${pkg}/bin/oh-my-opencode${ext}`;
}

148
bin/platform.test.ts Normal file
View File

@ -0,0 +1,148 @@
// bin/platform.test.ts
import { describe, expect, test } from "bun:test";
import { getPlatformPackage, getBinaryPath } from "./platform.js";
describe("getPlatformPackage", () => {
// #region Darwin platforms
test("returns darwin-arm64 for macOS ARM64", () => {
// #given macOS ARM64 platform
const input = { platform: "darwin", arch: "arm64" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name
expect(result).toBe("oh-my-opencode-darwin-arm64");
});
test("returns darwin-x64 for macOS Intel", () => {
// #given macOS x64 platform
const input = { platform: "darwin", arch: "x64" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name
expect(result).toBe("oh-my-opencode-darwin-x64");
});
// #endregion
// #region Linux glibc platforms
test("returns linux-x64 for Linux x64 with glibc", () => {
// #given Linux x64 with glibc
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name
expect(result).toBe("oh-my-opencode-linux-x64");
});
test("returns linux-arm64 for Linux ARM64 with glibc", () => {
// #given Linux ARM64 with glibc
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name
expect(result).toBe("oh-my-opencode-linux-arm64");
});
// #endregion
// #region Linux musl platforms
test("returns linux-x64-musl for Alpine x64", () => {
// #given Linux x64 with musl (Alpine)
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name with musl suffix
expect(result).toBe("oh-my-opencode-linux-x64-musl");
});
test("returns linux-arm64-musl for Alpine ARM64", () => {
// #given Linux ARM64 with musl (Alpine)
const input = { platform: "linux", arch: "arm64", libcFamily: "musl" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name with musl suffix
expect(result).toBe("oh-my-opencode-linux-arm64-musl");
});
// #endregion
// #region Windows platform
test("returns windows-x64 for Windows", () => {
// #given Windows x64 platform (win32 is Node's platform name)
const input = { platform: "win32", arch: "x64" };
// #when getting platform package
const result = getPlatformPackage(input);
// #then returns correct package name with 'windows' not 'win32'
expect(result).toBe("oh-my-opencode-windows-x64");
});
// #endregion
// #region Error cases
test("throws error for Linux with null libcFamily", () => {
// #given Linux platform with null libc detection
const input = { platform: "linux", arch: "x64", libcFamily: null };
// #when getting platform package
// #then throws descriptive error
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
});
test("throws error for Linux with undefined libcFamily", () => {
// #given Linux platform with undefined libc
const input = { platform: "linux", arch: "x64", libcFamily: undefined };
// #when getting platform package
// #then throws descriptive error
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
});
// #endregion
});
describe("getBinaryPath", () => {
test("returns path without .exe for Unix platforms", () => {
// #given Unix platform package
const pkg = "oh-my-opencode-darwin-arm64";
const platform = "darwin";
// #when getting binary path
const result = getBinaryPath(pkg, platform);
// #then returns path without extension
expect(result).toBe("oh-my-opencode-darwin-arm64/bin/oh-my-opencode");
});
test("returns path with .exe for Windows", () => {
// #given Windows platform package
const pkg = "oh-my-opencode-windows-x64";
const platform = "win32";
// #when getting binary path
const result = getBinaryPath(pkg, platform);
// #then returns path with .exe extension
expect(result).toBe("oh-my-opencode-windows-x64/bin/oh-my-opencode.exe");
});
test("returns path without .exe for Linux", () => {
// #given Linux platform package
const pkg = "oh-my-opencode-linux-x64";
const platform = "linux";
// #when getting binary path
const result = getBinaryPath(pkg, platform);
// #then returns path without extension
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
});
});

View File

@ -6,10 +6,12 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"type": "module", "type": "module",
"bin": { "bin": {
"oh-my-opencode": "./dist/cli/index.js" "oh-my-opencode": "./bin/oh-my-opencode.js"
}, },
"files": [ "files": [
"dist" "dist",
"bin",
"postinstall.mjs"
], ],
"exports": { "exports": {
".": { ".": {
@ -20,8 +22,11 @@
}, },
"scripts": { "scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
"build:all": "bun run build && bun run build:binaries",
"build:binaries": "bun run script/build-binaries.ts",
"build:schema": "bun run script/build-schema.ts", "build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"postinstall": "node postinstall.mjs",
"prepublishOnly": "bun run clean && bun run build", "prepublishOnly": "bun run clean && bun run build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "bun test" "test": "bun test"
@ -55,6 +60,7 @@
"@opencode-ai/plugin": "^1.1.19", "@opencode-ai/plugin": "^1.1.19",
"@opencode-ai/sdk": "^1.1.19", "@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2", "commander": "^14.0.2",
"detect-libc": "^2.0.0",
"hono": "^4.10.4", "hono": "^4.10.4",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
@ -70,6 +76,15 @@
"bun-types": "latest", "bun-types": "latest",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "0.0.0",
"oh-my-opencode-darwin-x64": "0.0.0",
"oh-my-opencode-linux-arm64": "0.0.0",
"oh-my-opencode-linux-arm64-musl": "0.0.0",
"oh-my-opencode-linux-x64": "0.0.0",
"oh-my-opencode-linux-x64-musl": "0.0.0",
"oh-my-opencode-windows-x64": "0.0.0"
},
"trustedDependencies": [ "trustedDependencies": [
"@ast-grep/cli", "@ast-grep/cli",
"@ast-grep/napi", "@ast-grep/napi",

View File

View File

@ -0,0 +1,16 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["darwin"],
"cpu": ["arm64"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,16 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["darwin"],
"cpu": ["x64"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,17 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["linux"],
"cpu": ["arm64"],
"libc": ["musl"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,17 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["linux"],
"cpu": ["arm64"],
"libc": ["glibc"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,17 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["linux"],
"cpu": ["x64"],
"libc": ["musl"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,17 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["linux"],
"cpu": ["x64"],
"libc": ["glibc"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

View File

@ -0,0 +1,16 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "0.0.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": ["win32"],
"cpu": ["x64"],
"files": ["bin"],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode.exe"
}
}

43
postinstall.mjs Normal file
View File

@ -0,0 +1,43 @@
// postinstall.mjs
// Runs after npm install to verify platform binary is available
import { createRequire } from "node:module";
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
const require = createRequire(import.meta.url);
/**
* Detect libc family on Linux
*/
function getLibcFamily() {
if (process.platform !== "linux") {
return undefined;
}
try {
const detectLibc = require("detect-libc");
return detectLibc.familySync();
} catch {
return null;
}
}
function main() {
const { platform, arch } = process;
const libcFamily = getLibcFamily();
try {
const pkg = getPlatformPackage({ platform, arch, libcFamily });
const binPath = getBinaryPath(pkg, platform);
// Try to resolve the binary
require.resolve(binPath);
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
} catch (error) {
console.warn(`⚠ oh-my-opencode: ${error.message}`);
console.warn(` The CLI may not work on this platform.`);
// Don't fail installation - let user try anyway
}
}
main();

103
script/build-binaries.ts Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env bun
// script/build-binaries.ts
// Build platform-specific binaries for CLI distribution
import { $ } from "bun";
import { existsSync } from "node:fs";
import { join } from "node:path";
interface PlatformTarget {
dir: string;
target: string;
binary: string;
description: string;
}
const PLATFORMS: PlatformTarget[] = [
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
];
const ENTRY_POINT = "src/cli/index.ts";
async function buildPlatform(platform: PlatformTarget): Promise<boolean> {
const outfile = join("packages", platform.dir, "bin", platform.binary);
console.log(`\n📦 Building ${platform.description}...`);
console.log(` Target: ${platform.target}`);
console.log(` Output: ${outfile}`);
try {
await $`bun build --compile --minify --sourcemap --bytecode --target=${platform.target} ${ENTRY_POINT} --outfile=${outfile}`;
// Verify binary exists
if (!existsSync(outfile)) {
console.error(` ❌ Binary not found after build: ${outfile}`);
return false;
}
// Verify binary with file command (skip on Windows host for non-Windows targets)
if (process.platform !== "win32") {
const fileInfo = await $`file ${outfile}`.text();
console.log(`${fileInfo.trim()}`);
} else {
console.log(` ✓ Binary created successfully`);
}
return true;
} catch (error) {
console.error(` ❌ Build failed: ${error}`);
return false;
}
}
async function main() {
console.log("🔨 Building oh-my-opencode platform binaries");
console.log(` Entry point: ${ENTRY_POINT}`);
console.log(` Platforms: ${PLATFORMS.length}`);
// Verify entry point exists
if (!existsSync(ENTRY_POINT)) {
console.error(`\n❌ Entry point not found: ${ENTRY_POINT}`);
process.exit(1);
}
const results: { platform: string; success: boolean }[] = [];
for (const platform of PLATFORMS) {
const success = await buildPlatform(platform);
results.push({ platform: platform.description, success });
}
// Summary
console.log("\n" + "=".repeat(50));
console.log("Build Summary:");
console.log("=".repeat(50));
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
for (const result of results) {
const icon = result.success ? "✓" : "✗";
console.log(` ${icon} ${result.platform}`);
}
console.log("=".repeat(50));
console.log(`Total: ${succeeded} succeeded, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
console.log("\n✅ All platform binaries built successfully!\n");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@ -1,12 +1,24 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun" import { $ } from "bun"
import { existsSync } from "node:fs"
import { join } from "node:path"
const PACKAGE_NAME = "oh-my-opencode" const PACKAGE_NAME = "oh-my-opencode"
const bump = process.env.BUMP as "major" | "minor" | "patch" | undefined const bump = process.env.BUMP as "major" | "minor" | "patch" | undefined
const versionOverride = process.env.VERSION const versionOverride = process.env.VERSION
console.log("=== Publishing oh-my-opencode ===\n") const PLATFORM_PACKAGES = [
"darwin-arm64",
"darwin-x64",
"linux-x64",
"linux-arm64",
"linux-x64-musl",
"linux-arm64-musl",
"windows-x64",
]
console.log("=== Publishing oh-my-opencode (multi-package) ===\n")
async function fetchPreviousVersion(): Promise<string> { async function fetchPreviousVersion(): Promise<string> {
try { try {
@ -22,7 +34,9 @@ async function fetchPreviousVersion(): Promise<string> {
} }
function bumpVersion(version: string, type: "major" | "minor" | "patch"): string { function bumpVersion(version: string, type: "major" | "minor" | "patch"): string {
const [major, minor, patch] = version.split(".").map(Number) // Handle prerelease versions (e.g., 3.0.0-beta.7)
const baseVersion = version.split("-")[0]
const [major, minor, patch] = baseVersion.split(".").map(Number)
switch (type) { switch (type) {
case "major": case "major":
return `${major + 1}.0.0` return `${major + 1}.0.0`
@ -33,14 +47,42 @@ function bumpVersion(version: string, type: "major" | "minor" | "patch"): string
} }
} }
async function updatePackageVersion(newVersion: string): Promise<void> { async function updatePackageVersion(pkgPath: string, newVersion: string): Promise<void> {
const pkgPath = new URL("../package.json", import.meta.url).pathname
let pkg = await Bun.file(pkgPath).text() let pkg = await Bun.file(pkgPath).text()
pkg = pkg.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`) pkg = pkg.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`)
await Bun.file(pkgPath).write(pkg) await Bun.write(pkgPath, pkg)
console.log(`Updated: ${pkgPath}`) console.log(`Updated: ${pkgPath}`)
} }
async function updateAllPackageVersions(newVersion: string): Promise<void> {
console.log("\nSyncing version across all packages...")
// Update main package.json
const mainPkgPath = new URL("../package.json", import.meta.url).pathname
await updatePackageVersion(mainPkgPath, newVersion)
// Update optionalDependencies versions in main package.json
let mainPkg = await Bun.file(mainPkgPath).text()
for (const platform of PLATFORM_PACKAGES) {
const pkgName = `oh-my-opencode-${platform}`
mainPkg = mainPkg.replace(
new RegExp(`"${pkgName}": "[^"]+"`),
`"${pkgName}": "${newVersion}"`
)
}
await Bun.write(mainPkgPath, mainPkg)
// Update each platform package.json
for (const platform of PLATFORM_PACKAGES) {
const pkgPath = new URL(`../packages/${platform}/package.json`, import.meta.url).pathname
if (existsSync(pkgPath)) {
await updatePackageVersion(pkgPath, newVersion)
} else {
console.warn(`Warning: ${pkgPath} not found`)
}
}
}
async function generateChangelog(previous: string): Promise<string[]> { async function generateChangelog(previous: string): Promise<string[]> {
const notes: string[] = [] const notes: string[] = []
@ -113,28 +155,96 @@ function getDistTag(version: string): string | null {
return tag || "next" return tag || "next"
} }
async function buildAndPublish(version: string): Promise<void> { interface PublishResult {
console.log("\nBuilding before publish...") success: boolean
await $`bun run clean && bun run build` alreadyPublished?: boolean
error?: string
}
console.log("\nPublishing to npm...") async function publishPackage(cwd: string, distTag: string | null): Promise<PublishResult> {
const distTag = getDistTag(version)
const tagArgs = distTag ? ["--tag", distTag] : [] const tagArgs = distTag ? ["--tag", distTag] : []
const provenanceArgs = process.env.CI ? ["--provenance"] : []
if (process.env.CI) { try {
await $`npm publish --access public --provenance --ignore-scripts ${tagArgs}` await $`npm publish --access public --ignore-scripts ${provenanceArgs} ${tagArgs}`.cwd(cwd)
} else { return { success: true }
await $`npm publish --access public --ignore-scripts ${tagArgs}` } catch (error: any) {
const stderr = error?.stderr?.toString() || error?.message || ""
// E409 = version already exists (idempotent success)
if (
stderr.includes("EPUBLISHCONFLICT") ||
stderr.includes("E409") ||
stderr.includes("cannot publish over") ||
stderr.includes("already exists")
) {
return { success: true, alreadyPublished: true }
}
return { success: false, error: stderr }
} }
} }
async function publishAllPackages(version: string): Promise<void> {
const distTag = getDistTag(version)
console.log("\n📦 Publishing platform packages...")
// Publish platform packages first
for (const platform of PLATFORM_PACKAGES) {
const pkgDir = join(process.cwd(), "packages", platform)
const pkgName = `oh-my-opencode-${platform}`
console.log(`\n Publishing ${pkgName}...`)
const result = await publishPackage(pkgDir, distTag)
if (result.success) {
if (result.alreadyPublished) {
console.log(`${pkgName}@${version} (already published)`)
} else {
console.log(`${pkgName}@${version}`)
}
} else {
console.error(`${pkgName} failed: ${result.error}`)
throw new Error(`Failed to publish ${pkgName}`)
}
}
// Publish main package last
console.log(`\n📦 Publishing main package...`)
const mainResult = await publishPackage(process.cwd(), distTag)
if (mainResult.success) {
if (mainResult.alreadyPublished) {
console.log(`${PACKAGE_NAME}@${version} (already published)`)
} else {
console.log(`${PACKAGE_NAME}@${version}`)
}
} else {
console.error(`${PACKAGE_NAME} failed: ${mainResult.error}`)
throw new Error(`Failed to publish ${PACKAGE_NAME}`)
}
}
async function buildPackages(): Promise<void> {
console.log("\nBuilding packages...")
await $`bun run clean && bun run build`
console.log("Building platform binaries...")
await $`bun run build:binaries`
}
async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> { async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> {
if (!process.env.CI) return if (!process.env.CI) return
console.log("\nCommitting and tagging...") console.log("\nCommitting and tagging...")
await $`git config user.email "github-actions[bot]@users.noreply.github.com"` await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
await $`git config user.name "github-actions[bot]"` await $`git config user.name "github-actions[bot]"`
// Add all package.json files
await $`git add package.json assets/oh-my-opencode.schema.json` await $`git add package.json assets/oh-my-opencode.schema.json`
for (const platform of PLATFORM_PACKAGES) {
await $`git add packages/${platform}/package.json`.nothrow()
}
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow() const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
if (hasStagedChanges.exitCode !== 0) { if (hasStagedChanges.exitCode !== 0) {
@ -181,15 +291,16 @@ async function main() {
process.exit(0) process.exit(0)
} }
await updatePackageVersion(newVersion) await updateAllPackageVersions(newVersion)
const changelog = await generateChangelog(previous) const changelog = await generateChangelog(previous)
const contributors = await getContributors(previous) const contributors = await getContributors(previous)
const notes = [...changelog, ...contributors] const notes = [...changelog, ...contributors]
await buildAndPublish(newVersion) await buildPackages()
await publishAllPackages(newVersion)
await gitTagAndRelease(newVersion, notes) await gitTagAndRelease(newVersion, notes)
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} ===`) console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} (8 packages) ===`)
} }
main() main()

View File

@ -8,8 +8,8 @@ import type { InstallArgs } from "./types"
import type { RunOptions } from "./run" import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types" import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor" import type { DoctorOptions } from "./doctor"
import packageJson from "../../package.json" with { type: "json" }
const packageJson = await import("../../package.json")
const VERSION = packageJson.version const VERSION = packageJson.version
const program = new Command() const program = new Command()

View File

@ -10,8 +10,8 @@ import {
addProviderConfig, addProviderConfig,
detectCurrentConfig, detectCurrentConfig,
} from "./config-manager" } from "./config-manager"
import packageJson from "../../package.json" with { type: "json" }
const packageJson = await import("../../package.json")
const VERSION = packageJson.version const VERSION = packageJson.version
const SYMBOLS = { const SYMBOLS = {