Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions MIGRATION-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Bun → Node.js Migration Plan

Status: In progress. See below for completed and remaining steps.

## Completed

### Phase 4 (early): Package Manager Switch
- [x] Changed `packageManager` from `bun@1.3.13` to `pnpm@10.11.0`
- [x] Moved `patchedDependencies` into `pnpm` config section
- [x] Added `onlyBuiltDependencies: ["esbuild"]`
- [x] Added phantom deps as explicit devDependencies: `@sentry/core`, `@clack/prompts`
- [x] Generated `pnpm-lock.yaml`
- [x] Verified all patches apply (including cross-version: `@stricli/core@1.2.5` patch on `1.2.7`)

### Phase 2, Group D: SQLite Adapter
- [x] Created `src/lib/db/sqlite.ts` — runtime-detecting adapter (bun:sqlite under Bun, node:sqlite under Node.js)
- [x] Updated 4 source files: `db/index.ts`, `schema.ts`, `migration.ts`, `utils.ts`
- [x] Updated 3 test files: `fix.test.ts`, `telemetry.test.ts`, `schema.test.ts`
- [x] Zero `bun:sqlite` imports remain in `src/` or `test/`

## Remaining

### Phase 2: Source Code Migration (replace Bun.* APIs in `src/`)

**Group A: File I/O** — Replace `Bun.file()` / `Bun.write()` with `node:fs/promises`
- `Bun.file(path).text()` → `readFile(path, "utf-8")`
- `Bun.file(path).json()` → `readFile(path, "utf-8")` then `JSON.parse()`
- `Bun.file(path).exists()` → `access(path).then(() => true, () => false)`
- `Bun.write(path, content)` → `writeFile(path, content)`
- Scan all of `src/` for occurrences

**Group B: Process/System APIs** — Replace Bun.which / Bun.spawn / Bun.sleep
- `Bun.which("cmd")` → `which` from a Node.js-compatible package or custom implementation
- `Bun.spawn()` / `Bun.spawnSync()` → `child_process.spawn()` / `spawnSync()`
- `Bun.sleep(ms)` → `setTimeout` promise wrapper

**Group C: Miscellaneous Bun APIs**
- `Bun.Glob` → `tinyglobby` or `picomatch` (already in devDependencies)
- `Bun.randomUUIDv7()` → `uuidv7` package (already in devDependencies)
- `Bun.semver.order()` → `semver.compare()` (already in devDependencies)
- `Bun.zstdCompressSync()` / `Bun.zstdDecompressSync()` → Node.js zlib or `zstd-napi` package

**Group E: Unpolyfilled APIs**
- `bspatch.ts` and `upgrade.ts` — Replace any Bun-specific APIs not covered by node-polyfills.ts

### Phase 3: Test Migration (`bun:test` → Vitest)

- Add `vitest` as devDependency
- Replace `import { ... } from "bun:test"` with Vitest equivalents
- Replace `bun test` scripts with `vitest`
- Key differences:
- `bun:test`'s `mock.module()` → Vitest's `vi.mock()`
- `bun:test`'s `spyOn` → Vitest's `vi.spyOn()`
- Test file discovery patterns may differ
- `--isolate --parallel` behavior needs Vitest equivalent

### Phase 4: CI & Dev Scripts (remaining)

- Update `package.json` scripts: `bun run` → `pnpm run` where appropriate
- Replace `bun run src/bin.ts` with `tsx src/bin.ts` (add `tsx` devDependency)
- Replace `bun run script/*.ts` with `tsx script/*.ts`
- Replace `bunx` with `pnpm exec`
- Update GitHub Actions workflows to use pnpm + Node.js instead of Bun
- Update `Dockerfile` / build scripts if applicable

### Phase 5: Cleanup

- Remove `@types/bun` from devDependencies
- Remove `bun.lock` (replaced by `pnpm-lock.yaml`)
- Remove or update `script/node-polyfills.ts` (may become unnecessary)
- Update `AGENTS.md` Bun API reference table
- Remove Bun-specific `.cursor/rules/bun-cli.mdc` or update for Node.js
- Clean up any remaining `Bun.*` references in comments/docs

## Known Issues

- `test/lib/index.test.ts` — `sdk.run throws when auth is required but missing` fails under pnpm's strict `node_modules`. The mock fetch returns empty 200s which prevents the expected auth error from being thrown. Pre-existing test fragility, not caused by migration changes.
5 changes: 3 additions & 2 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* SQLite database connection manager for CLI configuration storage.
* Uses bun:sqlite natively; Node.js uses a polyfill in node-polyfills.ts.
* Uses the sqlite.ts adapter which wraps node:sqlite's DatabaseSync
* with a bun:sqlite-compatible API surface.
*/

import { Database } from "bun:sqlite";
import { Database } from "./sqlite.js";
import { chmodSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getEnv } from "../env.js";
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* One-time migration from config.json to SQLite.
*/

import type { Database } from "bun:sqlite";
import type { Database } from "./sqlite.js";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { logger } from "../logger.js";
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - Migration checks
*/

import type { Database } from "bun:sqlite";
import type { Database } from "./sqlite.js";
import { getEnv } from "../env.js";
import { stringifyUnknown } from "../errors.js";
import { logger } from "../logger.js";
Expand Down
116 changes: 116 additions & 0 deletions src/lib/db/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* SQLite adapter wrapping node:sqlite's DatabaseSync with a convenient API.
*
* This module is the single import point for all SQLite access in the
* codebase. It provides a `.query(sql).get()` / `.all()` / `.run()`
* interface and a manual `transaction()` wrapper.
*
* Uses `node:sqlite` (Node 22+) as the backing implementation. Falls back
* to `bun:sqlite` when `node:sqlite` is unavailable (Bun runtime) — this
* fallback will be removed once the test runner migrates off Bun.
*/

/** Valid SQLite binding value */
export type SQLQueryBindings =
| string
| number
| bigint
| null
| Uint8Array
| undefined;

/**
* Prepared statement wrapper exposing `.get()`, `.all()`, `.run()`.
*/
class StatementWrapper {
// biome-ignore lint/suspicious/noExplicitAny: backing driver types vary
private readonly stmt: any;

// biome-ignore lint/suspicious/noExplicitAny: backing driver types vary
constructor(stmt: any) {
this.stmt = stmt;
}

get(
...params: SQLQueryBindings[]
): Record<string, SQLQueryBindings> | undefined {
return this.stmt.get(...params) as
| Record<string, SQLQueryBindings>
| undefined;
}

all(...params: SQLQueryBindings[]): Record<string, SQLQueryBindings>[] {
return this.stmt.all(...params) as Record<string, SQLQueryBindings>[];
}

run(...params: SQLQueryBindings[]): void {
this.stmt.run(...params);
}
}

/** Resolve the underlying SQLite constructor. */
function getSqliteConstructor(): new (path: string) => {
exec(sql: string): void;
prepare(sql: string): unknown;
close(): void;
} {
try {
// Primary: node:sqlite (Node 22+)
return require("node:sqlite").DatabaseSync;
} catch {
// Fallback: bun:sqlite — remove once test runner migrates off Bun
return require("bun:sqlite").Database;
}
}

// biome-ignore lint/suspicious/noExplicitAny: resolved dynamically
const SqliteImpl: any = getSqliteConstructor();

/**
* SQLite database wrapper.
*
* - `exec(sql)` — execute raw SQL (DDL, multi-statement)
* - `query(sql)` — prepare a statement → `.get()` / `.all()` / `.run()`
* - `close()` — close the connection
* - `transaction(fn)` — wrap a function in BEGIN/COMMIT/ROLLBACK
*/
export class Database {
// biome-ignore lint/suspicious/noExplicitAny: backing driver resolved at runtime
private readonly db: any;

constructor(path: string) {
this.db = new SqliteImpl(path);
}

exec(sql: string): void {
this.db.exec(sql);

Check failure on line 86 in src/lib/db/sqlite.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

`isSchemaError` and `isReadonlyError` never trigger under Node.js, silently disabling auto-repair

The error-classification functions in `schema.ts` (lines 595, 615) gate on `error.name === 'SQLiteError'`, which is the `bun:sqlite` error class name. `node:sqlite` throws plain `Error` instances with `error.code === 'ERR_SQLITE_ERROR'` and `error.name === 'Error'`, so both functions always return `false` on Node.js — disabling the entire auto-repair system and read-only detection. Add a check for `error.code === 'ERR_SQLITE_ERROR'` alongside the existing name check in `schema.ts`.
Comment on lines +84 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSchemaError and isReadonlyError never trigger under Node.js, silently disabling auto-repair

The error-classification functions in schema.ts (lines 595, 615) gate on error.name === 'SQLiteError', which is the bun:sqlite error class name. node:sqlite throws plain Error instances with error.code === 'ERR_SQLITE_ERROR' and error.name === 'Error', so both functions always return false on Node.js — disabling the entire auto-repair system and read-only detection. Add a check for error.code === 'ERR_SQLITE_ERROR' alongside the existing name check in schema.ts.

Verification

Traced the full path: Database.exec() (sqlite.ts:84) and StatementWrapper.run() (sqlite.ts:49) are now the only error-throwing surfaces for callers. Under node:sqlite, all SQLite-level errors are thrown as standard Error instances with err.code === 'ERR_SQLITE_ERROR' (per Node.js 22 docs and source). Under bun:sqlite they are SQLiteError instances. schema.ts:595 checks error instanceof Error && error.name === 'SQLiteError' — this is the bun:sqlite class name, and will never be true for node:sqlite errors. Consequently tryRepairAndRetry(), isSchemaError(), and isReadonlyError() all silently return false on Node.js, meaning: (a) schema auto-repair never runs, (b) read-only database errors are never surfaced correctly. No test exists that exercises these functions with a real node:sqlite error object.

Identified by Warden find-bugs · 9N4-5DY

}

query(sql: string): StatementWrapper {
// node:sqlite uses .prepare(), bun:sqlite uses .query()
const prepFn = this.db.prepare ?? this.db.query;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullish coalescing operands reversed in query method

Medium Severity

The expression this.db.prepare ?? this.db.query always resolves to .prepare() because both node:sqlite's DatabaseSync and bun:sqlite's Database define a .prepare() method — it's never nullish. The ?? this.db.query fallback is dead code. The comment says "bun:sqlite uses .query()" but .query() is never actually called. Under bun:sqlite, .query() caches compiled statement bytecode while .prepare() does not, so this is a performance regression. More critically, bun:sqlite has a known bug where .prepare() ignores single binding arguments (passing NULL instead), which could cause query failures. The operands likely need to be reversed to this.db.query ?? this.db.prepare.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 972b864. Configure here.

return new StatementWrapper(prepFn.call(this.db, sql));
}

close(): void {
this.db.close();
}

transaction<T>(fn: () => T): () => T {
// bun:sqlite has native transaction(); node:sqlite does not
if (typeof this.db.transaction === "function") {
return this.db.transaction(fn);
}
return () => {
this.db.exec("BEGIN");
try {
const result = fn();
this.db.exec("COMMIT");
return result;
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}

Check warning on line 113 in src/lib/db/sqlite.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

ROLLBACK exec failure silently discards the original transaction error

If `fn()` throws and `this.db.exec('ROLLBACK')` also throws (e.g., connection is closed or already in an error state), the ROLLBACK exception propagates and the original `error` is permanently lost, making failures very hard to diagnose. Use a try/catch around the ROLLBACK call and re-throw the original error regardless.
Comment on lines +107 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ROLLBACK exec failure silently discards the original transaction error

If fn() throws and this.db.exec('ROLLBACK') also throws (e.g., connection is closed or already in an error state), the ROLLBACK exception propagates and the original error is permanently lost, making failures very hard to diagnose. Use a try/catch around the ROLLBACK call and re-throw the original error regardless.

Verification

Lines 107-113 of the manual node:sqlite transaction path: the catch (error) block calls this.db.exec('ROLLBACK') directly. If that exec call itself throws (SQLite error on an already-aborted transaction, or a closed database), the new exception replaces error before throw error on line 112 executes. The bun:sqlite path (native transaction()) does not have this problem. No test exists that covers this error-within-error path. Fix: wrap ROLLBACK in its own try/catch and always re-throw the captured error.

Identified by Warden find-bugs · HZ6-E88

};
}
}
4 changes: 2 additions & 2 deletions src/lib/db/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* Reduces boilerplate for UPSERT and other repetitive patterns.
*/

import type { SQLQueryBindings } from "bun:sqlite";
import type { SQLQueryBindings } from "./sqlite.js";

import { getDatabase } from "./index.js";

/** Valid SQLite binding value (matches bun:sqlite's SQLQueryBindings) */
/** Valid SQLite binding value (re-exported from sqlite.ts adapter) */
export type SqlValue = SQLQueryBindings;

/**
Expand Down
2 changes: 1 addition & 1 deletion test/commands/cli/fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* and code spans have backticks stripped.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../../src/lib/db/sqlite.js";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { chmodSync, statSync } from "node:fs";
import { join } from "node:path";
Expand Down
2 changes: 1 addition & 1 deletion test/lib/db/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for database schema repair functions.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../../src/lib/db/sqlite.js";
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
Expand Down
2 changes: 1 addition & 1 deletion test/lib/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Tests for withTelemetry wrapper and opt-out behavior.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../src/lib/db/sqlite.js";
import {
afterAll,
afterEach,
Expand Down Expand Up @@ -857,7 +857,7 @@
const stmt = tracedDb.query("SELECT * FROM test WHERE id = ?");

// These should pass through without tracing
expect(stmt.columnNames).toEqual(["id", "name"]);

Check failure on line 860 in test/lib/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

error: expect(received).toEqual(expected)

- [ - "id", - "name", - ] + undefined - Expected - 4 + Received + 1 at <anonymous> (/home/runner/work/cli/cli/test/lib/telemetry.test.ts:860:30)
expect(typeof stmt.toString).toBe("function");

db.close();
Expand All @@ -872,7 +872,7 @@

// toString() requires proper 'this' binding to access native private fields
const sqlString = stmt.toString();
expect(sqlString).toContain("SELECT * FROM test");

Check failure on line 875 in test/lib/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

error: expect(received).toContain(expected)

Expected to contain: "SELECT * FROM test" Received: "[object Object]" at <anonymous> (/home/runner/work/cli/cli/test/lib/telemetry.test.ts:875:23)

// finalize() should work without errors
expect(() => stmt.finalize()).not.toThrow();
Expand Down Expand Up @@ -1012,7 +1012,7 @@
.all(2, "Bob");
const valuesResult = tracedDb
.query("INSERT INTO test (id, name) VALUES (?, ?)")
.values(3, "Charlie");

Check failure on line 1015 in test/lib/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

TypeError: tracedDb.query("INSERT INTO test (id

at <anonymous> (/home/runner/work/cli/cli/test/lib/telemetry.test.ts:1015:10)

expect(allResult).toEqual([]);
expect(valuesResult).toEqual([]);
Expand Down
Loading