Browse Source

feat(cli): run on plain Node so npm i -g works without Bun

`npm i -g` shipped a CLI that could not start on a machine without Bun. bin/oac.js
carried a `#!/usr/bin/env node` shebang and then immediately did
`execFileSync('bun', ...)`, falling back to "Error: Bun is required to run OAC CLI",
and 9 shipped source files called Bun-only APIs. This is the Windows/clean-machine
bug class the plan cites, and it blocked publishing under the new scope.

Converted the 9 shipped files off Bun (26 call sites):
  Bun.file().text/json/bytes  -> readFile
  Bun.write                   -> writeFile / copyFile
  Bun.file().exists()         -> stat().isFile()
  Bun.version                 -> process.versions.node
  import.meta.dir             -> dirname(fileURLToPath(import.meta.url))

Bun.write creates missing parent directories; fs.writeFile and fs.copyFile do not.
Every write site that relied on that now mkdirs explicitly — writeManifest depended
on it by name ("Creates the .oac/ directory if it does not exist"), so a naive swap
would have broken `oac init` outright.

The doctor's "Bun runtime >= 1.0.0" check is now "Node runtime >= 20.0.0"; checking
for a Bun we no longer need would be theatre. bin/oac.js re-execs process.execPath
rather than looking up a binary on PATH. engines follow: cli was `bun >=1.0.0` with
no node entry at all, root said node >=18; both now say node >=20. Build target moves
from --target bun to --target node. The 6 test files still use bun:test and Bun.write
— they are dev-only, never shipped, and converting them means swapping test runners
for no user-visible gain.

Also fixes a package-root bug that only a real install surfaces (07 Stage 5 predicted
it). findPackageRoot required `.opencode/` + `package.json` and the ABSENCE of
`registry.json`, to skip the monorepo root in dev — but the published tarball ships
registry.json beside .opencode/ at its root, so the walk rejected the very directory
it was looking for and `oac init` died with "could not find a directory with
.opencode/ and package.json" on every global install. It now matches on the
package.json `name` being @controlstack/oac, which holds in both layouts. Covered by
a regression test that mirrors the installed layout.

Marks oac-cli, compatibility-layer and eval-framework `private: true`. None was ever
meant to be published (08 §3: publish exactly one package) and all three were
publishable; `pnpm -r publish --dry-run` now resolves to @controlstack/oac alone.

Verified end-to-end with Bun removed from PATH: npm pack -> npm i -g -> oac init
installs 348 files -> oac doctor all green -> hand-edit a file -> oac update updates
347 and preserves the edit. cli tests 153 pass (was 150; +3 for the package root).
darrenhinde 2 weeks ago
parent
commit
8581a423a9

+ 3 - 5
bin/oac.js

@@ -12,12 +12,10 @@ if (!fs.existsSync(cliDist)) {
   process.exit(1);
 }
 
+// Re-exec the bundled CLI on the SAME Node binary that is running this shim, so
+// `oac` works wherever `npm i -g` could install it — no Bun, no PATH lookup.
 try {
-  execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  execFileSync(process.execPath, [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
 } catch (err) {
-  if (err.code === 'ENOENT') {
-    console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');
-    process.exit(1);
-  }
   process.exitCode = err.status ?? 1;
 }

+ 1 - 0
evals/framework/package.json

@@ -1,6 +1,7 @@
 {
   "name": "@controlstack/eval-framework",
   "version": "1.1.0",
+  "private": true,
   "type": "module",
   "description": "Evaluation framework for OpenCode agents",
   "main": "dist/index.js",

+ 1 - 1
package.json

@@ -33,7 +33,7 @@
     "packages/cli/dist/"
   ],
   "engines": {
-    "node": ">=18.0.0"
+    "node": ">=20.0.0"
   },
   "scripts": {
     "test": "pnpm run test:all",

+ 4 - 3
packages/cli/package.json

@@ -2,6 +2,7 @@
   "name": "@controlstack/oac-cli",
   "version": "1.1.0",
   "description": "OAC CLI — install, manage, and update AI agents and context files",
+  "private": true,
   "type": "module",
   "bin": {
     "oac": "./dist/index.js"
@@ -10,8 +11,8 @@
   "types": "./dist/index.d.ts",
   "files": ["dist"],
   "scripts": {
-    "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --splitting",
-    "build:watch": "bun build src/index.ts --outdir dist --target bun --splitting --watch",
+    "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --splitting",
+    "build:watch": "bun build src/index.ts --outdir dist --target node --splitting --watch",
     "dev": "bun run src/index.ts",
     "test": "bun test",
     "test:watch": "bun test --watch",
@@ -32,6 +33,6 @@
     "typescript": "^5.4.0"
   },
   "engines": {
-    "bun": ">=1.0.0"
+    "node": ">=20.0.0"
   }
 }

+ 7 - 5
packages/cli/src/commands/apply.ts

@@ -9,8 +9,8 @@
  */
 
 import type { Command } from 'commander'
-import { join } from 'node:path'
-import { stat } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { copyFile, mkdir, stat, writeFile } from 'node:fs/promises'
 import {
   loadAgents,
   CursorAdapter,
@@ -73,9 +73,10 @@ function reportFileSize(ide: IdeType, outputPath: string, sizeBytes: number): vo
 
 /** Backs up an existing file to `{file}.bak` before overwriting. */
 async function backupIfExists(filePath: string): Promise<void> {
-  if (await Bun.file(filePath).exists()) {
+  const exists = await stat(filePath).then((s) => s.isFile()).catch(() => false)
+  if (exists) {
     const backupPath = `${filePath}.bak`
-    await Bun.write(backupPath, Bun.file(filePath))
+    await copyFile(filePath, backupPath)
     dim(`  Backed up existing file → ${backupPath}`)
   }
 }
@@ -161,7 +162,8 @@ async function applyToIde(
       printDryRunPreview(outputPath, content)
     } else {
       await backupIfExists(outputPath)
-      await Bun.write(outputPath, content)
+      await mkdir(dirname(outputPath), { recursive: true })
+      await writeFile(outputPath, content)
     }
 
     reportWarnings(result, options.verbose)

+ 12 - 11
packages/cli/src/commands/doctor.ts

@@ -1,4 +1,5 @@
 import { join } from 'node:path';
+import { stat } from 'node:fs/promises';
 import { type Command } from 'commander';
 import semver from 'semver';
 
@@ -78,18 +79,18 @@ const checkOacVersion = async (): Promise<CheckResult> => {
   };
 };
 
-/** Check 2: Bun runtime version >= 1.0.0. */
-const checkBunVersion = (): CheckResult => {
-  const bunVersion = Bun.version; // e.g. "1.1.0" — global provided by @types/bun
-  const MIN_BUN = '1.0.0';
-  const isValid = semver.gte(bunVersion, MIN_BUN);
+/** Check 2: Node runtime version >= 20.0.0. */
+const checkNodeVersion = (): CheckResult => {
+  const nodeVersion = process.versions.node; // e.g. "20.11.0"
+  const MIN_NODE = '20.0.0';
+  const isValid = semver.gte(nodeVersion, MIN_NODE);
 
   return {
-    name: 'Bun runtime',
+    name: 'Node runtime',
     status: isValid ? 'ok' : 'error',
     message: isValid
-      ? `${bunVersion} (>= ${MIN_BUN} required)`
-      : `${bunVersion} is below minimum required ${MIN_BUN} — upgrade Bun`,
+      ? `${nodeVersion} (>= ${MIN_NODE} required)`
+      : `${nodeVersion} is below minimum required ${MIN_NODE} — upgrade Node`,
   };
 };
 
@@ -169,7 +170,7 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
   const missingFiles: string[] = [];
   for (const filePath of trackedFiles) {
     const absPath = join(projectRoot, filePath);
-    const exists = await Bun.file(absPath).exists();
+    const exists = await stat(absPath).then((s) => s.isFile()).catch(() => false);
     if (!exists) missingFiles.push(filePath);
   }
 
@@ -212,7 +213,7 @@ const checkModifiedFiles = async (projectRoot: string): Promise<CheckResult> =>
   const modifiedFiles: string[] = [];
   for (const filePath of trackedFiles) {
     const absPath = join(projectRoot, filePath);
-    const exists = await Bun.file(absPath).exists();
+    const exists = await stat(absPath).then((s) => s.isFile()).catch(() => false);
     if (!exists) continue; // Already reported by checkFilesOnDisk
 
     try {
@@ -347,7 +348,7 @@ export async function doctorCommand(options: DoctorOptions): Promise<void> {
     ]);
 
   const allResults: CheckResult[] = [
-    checkBunVersion(), // synchronous — call directly
+    checkNodeVersion(), // synchronous — call directly
     configResult,
     manifestResult,
     filesResult,

+ 79 - 20
packages/cli/src/lib/bundled.test.ts

@@ -123,12 +123,18 @@ describe('findPackageRoot', () => {
     await rm(tmpDir, { recursive: true, force: true });
   });
 
-  // ✅ Positive: finds a directory that has both .opencode/ and package.json
-  test('returns the directory that has both .opencode/ and package.json', async () => {
+  /** Writes a package.json with the given name at `dir`. */
+  const writePackageJson = async (dir: string, name: string): Promise<void> => {
+    await mkdir(dir, { recursive: true });
+    await writeFile(join(dir, 'package.json'), JSON.stringify({ name }), 'utf8');
+  };
+
+  // ✅ Positive: finds the directory whose package.json name is @controlstack/oac
+  test('returns the directory whose package.json is named @controlstack/oac', async () => {
     // Arrange — create a fake package root
     const fakeRoot = join(tmpDir, 'fake-pkg');
     await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
-    await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
+    await writePackageJson(fakeRoot, '@controlstack/oac');
     // Also create a subdirectory to start the walk from
     const startDir = join(fakeRoot, 'dist', 'lib');
     await mkdir(startDir, { recursive: true });
@@ -145,7 +151,7 @@ describe('findPackageRoot', () => {
     // Arrange
     const fakeRoot = join(tmpDir, 'exact-root');
     await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
-    await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
+    await writePackageJson(fakeRoot, '@controlstack/oac');
 
     // Act
     const result = findPackageRoot(fakeRoot);
@@ -154,6 +160,42 @@ describe('findPackageRoot', () => {
     expect(result).toBe(fakeRoot);
   });
 
+  // ✅ Regression: the published tarball ships registry.json NEXT TO .opencode/ at
+  // the package root. The previous heuristic excluded any directory containing a
+  // registry.json, so it walked straight past the real root and `oac init` failed
+  // on every `npm i -g` install. The root must still be found.
+  test('finds the package root even when registry.json sits beside .opencode/', async () => {
+    // Arrange — mirror the installed layout exactly
+    const installedRoot = join(tmpDir, 'installed-pkg');
+    await mkdir(join(installedRoot, '.opencode', 'agent'), { recursive: true });
+    await writePackageJson(installedRoot, '@controlstack/oac');
+    await writeFile(join(installedRoot, 'registry.json'), '{"components":[]}', 'utf8');
+    const startDir = join(installedRoot, 'packages', 'cli', 'dist');
+    await mkdir(startDir, { recursive: true });
+
+    // Act
+    const result = findPackageRoot(startDir);
+
+    // Assert
+    expect(result).toBe(installedRoot);
+  });
+
+  // ❌ Negative: a differently-named package.json must not match
+  test('walks past a package.json belonging to a different package', async () => {
+    // Arrange — @controlstack/oac-cli must NOT be mistaken for @controlstack/oac
+    const outer = join(tmpDir, 'named-outer');
+    await mkdir(join(outer, '.opencode'), { recursive: true });
+    await writePackageJson(outer, '@controlstack/oac');
+    const inner = join(outer, 'packages', 'cli');
+    await writePackageJson(inner, '@controlstack/oac-cli');
+
+    // Act — starting inside the CLI package, the walk must reach the outer root
+    const result = findPackageRoot(inner);
+
+    // Assert
+    expect(result).toBe(outer);
+  });
+
   // ❌ Negative: throws when no package root is found (isolated tmp dir with no markers)
   test('throws an error when no package root is found walking to filesystem root', async () => {
     // Arrange — a directory with neither .opencode/ nor package.json
@@ -181,7 +223,7 @@ describe('findPackageRoot', () => {
     // find the repo root. We therefore test the error *shape* by directly
     // calling with a path that we know will fail: the filesystem root '/'.
     expect(() => findPackageRoot('/')).toThrow(
-      'getPackageRoot: could not find a directory with ".opencode/" and "package.json"',
+      'getPackageRoot: could not find the "@controlstack/oac" package root',
     );
   });
 
@@ -197,27 +239,44 @@ describe('findPackageRoot', () => {
     expect(thrownMessage).toContain('"/"');
   });
 
-  // ❌ Negative: directory with only package.json (no .opencode) does not match
-  test('does not match a directory that has package.json but no .opencode', async () => {
-    // Arrange — a directory with only package.json, no .opencode
-    const noOpencode = join(tmpDir, 'no-opencode');
-    await mkdir(noOpencode, { recursive: true });
-    await writeFile(join(noOpencode, 'package.json'), '{}', 'utf8');
-    // Start from a child — the walk will pass through noOpencode (no match)
-    // and continue upward until it finds the monorepo root or throws.
-    // We just verify it does NOT return noOpencode.
+  // ❌ Negative: an unnamed package.json does not match
+  test('does not match a directory whose package.json has no name', async () => {
+    // Arrange — a directory with an anonymous package.json
+    const unnamed = join(tmpDir, 'unnamed-pkg');
+    await mkdir(unnamed, { recursive: true });
+    await writeFile(join(unnamed, 'package.json'), '{}', 'utf8');
+
+    // Act — the walk passes through and continues upward; it may find the real
+    // monorepo root above tmpdir, or throw. Either is fine.
     let result: string | undefined;
     try {
-      result = findPackageRoot(noOpencode);
+      result = findPackageRoot(unnamed);
     } catch {
       result = undefined;
     }
-    // If it found something, it must NOT be noOpencode (which lacks .opencode)
-    if (result !== undefined) {
-      expect(result).not.toBe(noOpencode);
+
+    // Assert — the key property: it never returns the unnamed directory
+    expect(result).not.toBe(unnamed);
+  });
+
+  // ❌ Negative: a malformed package.json is skipped rather than throwing
+  test('walks past a malformed package.json without throwing a parse error', async () => {
+    // Arrange
+    const broken = join(tmpDir, 'broken-pkg');
+    await mkdir(broken, { recursive: true });
+    await writeFile(join(broken, 'package.json'), '{ not json', 'utf8');
+
+    // Act & Assert — must not surface a JSON.parse error
+    let thrownMessage = '';
+    try {
+      const found = findPackageRoot(broken);
+      expect(found).not.toBe(broken);
+    } catch (err) {
+      thrownMessage = err instanceof Error ? err.message : String(err);
+    }
+    if (thrownMessage !== '') {
+      expect(thrownMessage).toContain('could not find the "@controlstack/oac" package root');
     }
-    // Either it threw (correct) or found a higher-level root (also acceptable)
-    expect(true).toBe(true); // test passes either way — the key is it didn't return noOpencode
   });
 });
 

+ 43 - 24
packages/cli/src/lib/bundled.ts

@@ -1,6 +1,7 @@
-import { existsSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
 import { readdir, stat } from "node:fs/promises";
-import { join, relative } from "node:path";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
 
 // --- Types ---
 
@@ -23,7 +24,6 @@ const BUNDLED_SUBDIRS = [
  * that contains both `.opencode/` and `package.json` — the npm package root.
  *
  * Works in both development (monorepo) and when installed via npm.
- * import.meta.dir is Bun's native equivalent of __dirname.
  */
 export function getPackageRoot(): string {
   // Allow dev/monorepo override via environment variable.
@@ -33,18 +33,41 @@ export function getPackageRoot(): string {
   if (envOverride) {
     return envOverride;
   }
-  // import.meta.dir is Bun's native equivalent of __dirname — points to packages/cli/dist/ at runtime
-  return findPackageRoot(import.meta.dir);
+  // Resolves to packages/cli/dist/ at runtime — the ESM equivalent of __dirname.
+  return findPackageRoot(dirname(fileURLToPath(import.meta.url)));
+}
+
+/** The published package whose root holds the bundled `.opencode/` tree. */
+const PACKAGE_NAME = "@controlstack/oac";
+
+/**
+ * Returns true when `dir` is the root of the OAC package itself — i.e. it holds
+ * a `package.json` whose `name` is exactly {@link PACKAGE_NAME}.
+ *
+ * The name is the only reliable anchor. Structural heuristics do not survive both
+ * layouts: an earlier version required `.opencode/` + `package.json` and the ABSENCE
+ * of `registry.json` (to skip the monorepo root in dev), but the published tarball
+ * ships `registry.json` NEXT TO `.opencode/` at its root — so that rule rejected the
+ * very directory it needed to find, and `oac init` could not locate its bundled files
+ * on a real `npm i -g` install.
+ */
+function isPackageRoot(dir: string): boolean {
+  const packageJsonPath = join(dir, "package.json");
+  if (!existsSync(packageJsonPath)) return false;
+
+  try {
+    const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
+    return parsed.name === PACKAGE_NAME;
+  } catch {
+    // Unreadable or malformed package.json — not a match; keep walking.
+    return false;
+  }
 }
 
 /**
- * Synchronously walks up from `dir` until finding a directory that has
- * all three anchors:
- *   1. `.opencode/`   — OAC configuration directory
- *   2. `package.json` — npm package manifest
- *   3. No `registry.json` at the same level — `registry.json` is present at
- *      the monorepo root but NOT at the CLI package root, so its absence
- *      distinguishes the CLI package from the repo root in a monorepo layout.
+ * Synchronously walks up from `dir` until it finds the OAC package root, as
+ * identified by {@link isPackageRoot}. Works identically in the monorepo (where
+ * the repo root is the package) and in an installed tree.
  *
  * Throws if the filesystem root is reached without finding a match.
  *
@@ -54,14 +77,7 @@ export function findPackageRoot(dir: string): string {
   let current = dir;
 
   while (true) {
-    const hasOpencode = existsSync(join(current, ".opencode"));
-    const hasPackageJson = existsSync(join(current, "package.json"));
-    // registry.json exists at the monorepo root but NOT at the CLI package root.
-    // Excluding directories that have it prevents the walk from stopping at the
-    // repo root instead of the actual CLI package root.
-    const hasRegistryJson = existsSync(join(current, "registry.json"));
-
-    if (hasOpencode && hasPackageJson && !hasRegistryJson) {
+    if (isPackageRoot(current)) {
       return current;
     }
 
@@ -69,9 +85,9 @@ export function findPackageRoot(dir: string): string {
     // Reached filesystem root — no package root found
     if (parent === current) {
       throw new Error(
-        `getPackageRoot: could not find a directory with ".opencode/" and "package.json" ` +
-          `(without a "registry.json" at the same level) walking up from "${dir}". ` +
-          `Is @controlstack/oac installed correctly? ` +
+        `getPackageRoot: could not find the "${PACKAGE_NAME}" package root ` +
+          `walking up from "${dir}". ` +
+          `Is ${PACKAGE_NAME} installed correctly? ` +
           `In dev/monorepo mode, set OAC_PACKAGE_ROOT env var to the repo root.`,
       );
     }
@@ -142,7 +158,10 @@ export async function listBundledFiles(packageRoot: string): Promise<string[]> {
 export const bundledFileExists = async (
   packageRoot: string,
   relativePath: string,
-): Promise<boolean> => Bun.file(getBundledFilePath(packageRoot, relativePath)).exists();
+): Promise<boolean> =>
+  stat(getBundledFilePath(packageRoot, relativePath))
+    .then((s) => s.isFile())
+    .catch(() => false);
 
 // --- Classification ---
 

+ 5 - 4
packages/cli/src/lib/config.ts

@@ -1,5 +1,5 @@
 import { z } from "zod";
-import { mkdir } from "node:fs/promises";
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
 import { join, dirname } from "node:path";
 
 export const OacPreferencesSchema = z.object({
@@ -34,8 +34,9 @@ export const isAutoBackup = (config: OacConfig): boolean =>
 
 export async function readConfig(projectRoot: string): Promise<OacConfig | null> {
   const configPath = getConfigPath(projectRoot);
-  if (!(await Bun.file(configPath).exists())) return null;
-  const raw = await Bun.file(configPath).json() as unknown;
+  const exists = await stat(configPath).then((s) => s.isFile()).catch(() => false);
+  if (!exists) return null;
+  const raw = JSON.parse(await readFile(configPath, "utf8")) as unknown;
   const result = OacConfigSchema.safeParse(raw);
   if (!result.success) {
     throw new Error(`Invalid config at "${configPath}": ${result.error.message}`);
@@ -46,5 +47,5 @@ export async function readConfig(projectRoot: string): Promise<OacConfig | null>
 export async function writeConfig(projectRoot: string, config: OacConfig): Promise<void> {
   const configPath = getConfigPath(projectRoot);
   await mkdir(dirname(configPath), { recursive: true });
-  await Bun.write(configPath, JSON.stringify(config, null, 2));
+  await writeFile(configPath, JSON.stringify(config, null, 2));
 }

+ 5 - 1
packages/cli/src/lib/ide-detect.ts

@@ -36,6 +36,10 @@ const IDE_OUTPUT_FILES: Record<IdeType, string> = {
 const dirExists = (p: string): Promise<boolean> =>
   stat(p).then((s) => s.isDirectory()).catch(() => false);
 
+/** Returns true if `p` is an existing file. Never throws. */
+const fileExists = (p: string): Promise<boolean> =>
+  stat(p).then((s) => s.isFile()).catch(() => false);
+
 /** Returns the indicator string and detected status for a single IDE. */
 async function checkIde(
   projectRoot: string,
@@ -57,7 +61,7 @@ async function checkIde(
     const dir = path.join(projectRoot, ".claude");
     const file = path.join(projectRoot, "CLAUDE.md");
     if (await dirExists(dir)) return { detected: true, indicator: ".claude/ directory" };
-    if (await Bun.file(file).exists()) return { detected: true, indicator: "CLAUDE.md file" };
+    if (await fileExists(file)) return { detected: true, indicator: "CLAUDE.md file" };
     return { detected: false, indicator: ".claude/ directory or CLAUDE.md (not found)" };
   }
   // windsurf

+ 17 - 7
packages/cli/src/lib/installer.ts

@@ -1,5 +1,5 @@
 import path from "node:path";
-import { stat } from "node:fs/promises";
+import { copyFile, mkdir, stat } from "node:fs/promises";
 import { computeFileHash, hashesMatch } from "./sha256.js";
 import {
   type ManifestFile,
@@ -103,7 +103,18 @@ export async function installFile(
     log(options, `[dry-run] would copy: ${sourcePath} → ${destPath}`);
     return;
   }
-  await Bun.write(destPath, Bun.file(sourcePath));
+  await copyFileCreatingDirs(sourcePath, destPath);
+}
+
+/**
+ * Copies `src` to `dest`, creating `dest`'s parent directories first.
+ *
+ * `fs.copyFile` fails when the destination directory does not exist, so the
+ * mkdir is required — every caller here writes into a tree that may not exist yet.
+ */
+async function copyFileCreatingDirs(src: string, dest: string): Promise<void> {
+  await mkdir(path.dirname(dest), { recursive: true });
+  await copyFile(src, dest);
 }
 
 /**
@@ -117,7 +128,7 @@ export async function backupFile(
   const timestamp = buildTimestamp();
   const relativePath = path.relative(projectRoot, filePath);
   const backupPath = buildBackupPath(projectRoot, timestamp, relativePath);
-  await Bun.write(backupPath, Bun.file(filePath));
+  await copyFileCreatingDirs(filePath, backupPath);
   return backupPath;
 }
 
@@ -147,7 +158,7 @@ async function decideFileAction(
   }
 
   // File is in manifest — check if user modified it
-  const diskExists = await Bun.file(destPath).exists();
+  const diskExists = await stat(destPath).then((s) => s.isFile()).catch(() => false);
   if (!diskExists) {
     // File was deleted by user — treat as new install
     return { action: "install" };
@@ -237,7 +248,7 @@ async function processOneFile(
       log(options, `yolo: backing up and overwriting ${relativePath}`);
       const backupPath = buildBackupPath(options.projectRoot, timestamp, relativePath);
       if (!options.dryRun) {
-        await Bun.write(backupPath, Bun.file(destPath));
+        await copyFileCreatingDirs(destPath, backupPath);
       } else {
         log(options, `[dry-run] would backup: ${destPath} → ${backupPath}`);
       }
@@ -405,9 +416,8 @@ export async function updateFiles(
  */
 export async function isProjectRoot(dir: string): Promise<boolean> {
   const [hasPackageJson, hasGit] = await Promise.all([
-    Bun.file(path.join(dir, "package.json")).exists(),
+    stat(path.join(dir, "package.json")).then((s) => s.isFile()).catch(() => false),
     // stat() works for both files (.git in worktrees) and directories (.git in normal repos)
-    // Bun.file().exists() returns false for directories, so we must use stat() here
     stat(path.join(dir, ".git")).then(() => true).catch(() => false),
   ]);
   return hasPackageJson || hasGit;

+ 5 - 3
packages/cli/src/lib/manifest.ts

@@ -1,4 +1,5 @@
 import path from 'node:path';
+import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
 import { z } from 'zod';
 
 // ── Errors ────────────────────────────────────────────────────────────────────
@@ -144,12 +145,12 @@ export const readManifest = async (
 ): Promise<ManifestFile | null> => {
   const manifestPath = getManifestPath(projectRoot);
 
-  const exists = await Bun.file(manifestPath).exists();
+  const exists = await stat(manifestPath).then((s) => s.isFile()).catch(() => false);
   if (!exists) {
     return null;
   }
 
-  const raw: unknown = await Bun.file(manifestPath).json();
+  const raw: unknown = JSON.parse(await readFile(manifestPath, 'utf8'));
 
   const result = ManifestFileSchema.safeParse(raw);
   if (!result.success) {
@@ -174,5 +175,6 @@ export const writeManifest = async (
   manifest: ManifestFile,
 ): Promise<void> => {
   const manifestPath = getManifestPath(projectRoot);
-  await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  await mkdir(path.dirname(manifestPath), { recursive: true });
+  await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
 };

+ 11 - 8
packages/cli/src/lib/registry.ts

@@ -1,4 +1,5 @@
 import { z } from "zod";
+import { readFile, stat } from "node:fs/promises";
 import { join } from "node:path";
 
 // ── Constants ──────────────────────────────────────────────────────────────────
@@ -171,7 +172,7 @@ export const getBundledSourcePath = (component: RegistryComponent): string =>
 export const readRegistry = async (packageRoot: string): Promise<Registry> => {
   const registryPath = getRegistryPath(packageRoot);
 
-  const exists = await Bun.file(registryPath).exists();
+  const exists = await stat(registryPath).then((s) => s.isFile()).catch(() => false);
   if (!exists) {
     throw new Error(
       `registry.json not found at "${registryPath}".\n` +
@@ -180,13 +181,15 @@ export const readRegistry = async (packageRoot: string): Promise<Registry> => {
     );
   }
 
-  const raw = await Bun.file(registryPath).json().catch((err: unknown) => {
-    const msg = err instanceof Error ? err.message : String(err);
-    throw new Error(
-      `Failed to parse registry.json at "${registryPath}": ${msg}\n` +
-        `The file may be corrupted. Try reinstalling: npm install -g @controlstack/oac`,
-    );
-  }) as unknown;
+  const raw = await readFile(registryPath, "utf8")
+    .then((text) => JSON.parse(text) as unknown)
+    .catch((err: unknown) => {
+      const msg = err instanceof Error ? err.message : String(err);
+      throw new Error(
+        `Failed to parse registry.json at "${registryPath}": ${msg}\n` +
+          `The file may be corrupted. Try reinstalling: npm install -g @controlstack/oac`,
+      );
+    });
 
   const result = RegistrySchema.safeParse(raw);
   if (!result.success) {

+ 2 - 2
packages/cli/src/lib/sha256.ts

@@ -1,9 +1,9 @@
 import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
 
 /** Returns the hex SHA256 of a file's contents. Throws if file does not exist. */
 export async function computeFileHash(filePath: string): Promise<string> {
-  return Bun.file(filePath)
-    .bytes()
+  return readFile(filePath)
     .then((contents) => createHash("sha256").update(contents).digest("hex"))
     .catch((err: unknown) => {
       const msg = err instanceof Error ? err.message : String(err);

+ 1 - 0
packages/compatibility-layer/package.json

@@ -1,6 +1,7 @@
 {
   "name": "@controlstack/compatibility-layer",
   "version": "1.1.0",
+  "private": true,
   "type": "module",
   "description": "Compatibility layer for converting OpenAgents Control agents to/from other AI coding tools (Cursor, Claude Code, Windsurf)",
   "main": "dist/index.js",