Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0811d7f
feat(cli): add S3-compatible storage setup to init command
weroperking Mar 4, 2026
adde9c6
feat(cli): add dev server with hot reload support
weroperking Mar 4, 2026
b254161
test: add test files for cli, client, core, and shared packages
weroperking Mar 4, 2026
88a6c7b
docs: update codebase map and simplify README documentation
weroperking Mar 4, 2026
d0dc2a9
refactor(core): use singular names for GraphQL schema types and updat…
weroperking Mar 4, 2026
2fe13dd
feat(test-project): update test project configuration
weroperking Mar 5, 2026
31d5ff2
feat(test-project): update authentication and middleware
weroperking Mar 5, 2026
67170b4
feat(test-project): update storage routes
weroperking Mar 5, 2026
bc04eca
feat(cli): update CLI commands
weroperking Mar 5, 2026
9979cc2
test(cli): update CLI test suite
weroperking Mar 5, 2026
e1f4111
test(client): update client library tests
weroperking Mar 5, 2026
6892a78
feat(core): update RLS and migration functionality
weroperking Mar 5, 2026
033f5f4
test(core): update core package test suite
weroperking Mar 5, 2026
0f17a02
test(shared): update shared package tests
weroperking Mar 5, 2026
0a14ff4
docs: add PR31 documentation and error references
weroperking Mar 5, 2026
44c627b
fix(core): conditionally include Subscription resolvers based on config
weroperking Mar 5, 2026
c106d80
Update storage routes in test project
weroperking Mar 5, 2026
d114759
Update auth command in CLI
weroperking Mar 5, 2026
9b5ce7c
Update dev command in CLI
weroperking Mar 5, 2026
c80b861
Update init command in CLI
weroperking Mar 5, 2026
0af0c53
Update login command in CLI
weroperking Mar 5, 2026
da8c6d1
Update CLI main index file
weroperking Mar 5, 2026
8474601
test(cli): set max event listeners for prompts tests
weroperking Mar 5, 2026
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
138 changes: 138 additions & 0 deletions 01_bb_dev_hot_reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
Document 1: bb dev Hot Reload
File: 01_bb_dev_hot_reload.md
The problem: bb dev only regenerates context. It never starts the server. The developer runs bun run dev in a separate terminal manually.
The fix: spawn bun --hot src/index.ts as a managed child process inside runDevCommand. Bun's --hot flag handles HMR natively — we just manage the process lifecycle.
Replace entire packages/cli/src/commands/dev.ts with:
typescriptimport path from "node:path";
import { existsSync } from "node:fs";
import { watch } from "node:fs";
import type { FSWatcher } from "node:fs";
import { ContextGenerator } from "../utils/context-generator";
import * as logger from "../utils/logger";

type BunSubprocess = ReturnType<typeof Bun.spawn>;

const RESTART_DELAY_MS = 1000;
const DEBOUNCE_MS = 250;
const SERVER_ENTRY = "src/index.ts";

class ServerManager {
private process: BunSubprocess | null = null;
private projectRoot: string;
private isShuttingDown = false;
private restartTimer: ReturnType<typeof setTimeout> | null = null;

constructor(projectRoot: string) {
this.projectRoot = projectRoot;
}

start(): void {
const entryPath = path.join(this.projectRoot, SERVER_ENTRY);
if (!existsSync(entryPath)) {
logger.error(
`Server entry not found: ${SERVER_ENTRY}\n` +
`Run bb dev from your project root.\n` +
`Expected: ${entryPath}`
);
process.exit(1);
}
this.spawn();
}

private spawn(): void {
if (this.isShuttingDown) return;
logger.info(`Starting server: bun --hot ${SERVER_ENTRY}`);
this.process = Bun.spawn({
cmd: ["bun", "--hot", SERVER_ENTRY],
cwd: this.projectRoot, // CRITICAL: must be project root, not CLI dir
stdout: "inherit", // pipe server logs directly to terminal
stderr: "inherit",
env: { ...process.env },
onExit: (_proc, exitCode, signalCode) => {
this.handleExit(exitCode, signalCode);
},
});
logger.success(`Server started (PID: ${this.process.pid})`);
}

private handleExit(exitCode: number | null, signalCode: string | null): void {
if (this.isShuttingDown) return; // we stopped it intentionally
if (signalCode) return; // we sent the signal
logger.error(`Server crashed (code ${exitCode ?? "unknown"}). Restarting in ${RESTART_DELAY_MS / 1000}s...`);
this.restartTimer = setTimeout(() => {
logger.info("Restarting server...");
this.spawn();
}, RESTART_DELAY_MS);
}

stop(): void {
this.isShuttingDown = true;
if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; }
if (this.process) { this.process.kill("SIGTERM"); this.process = null; }
}
}

export async function runDevCommand(projectRoot: string = process.cwd()): Promise<() => void> {
logger.info(`Starting BetterBase dev in: ${projectRoot}`);

const generator = new ContextGenerator();
try {
await generator.generate(projectRoot);
logger.success("Context generated.");
} catch (error) {
logger.warn(`Context generation failed: ${error instanceof Error ? error.message : String(error)}`);
}

const server = new ServerManager(projectRoot);
server.start();

const watchPaths = [
path.join(projectRoot, "src/db/schema.ts"),
path.join(projectRoot, "src/routes"),
];
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const watchers: FSWatcher[] = [];

for (const watchPath of watchPaths) {
if (!existsSync(watchPath)) { logger.warn(`Watch path missing, skipping: ${watchPath}`); continue; }
try {
const watcher = watch(watchPath, { recursive: true }, (_eventType, filename) => {
logger.info(`File changed: ${String(filename ?? "")}`);
const existing = timers.get(watchPath);
if (existing) clearTimeout(existing);
const timer = setTimeout(async () => {
logger.info("Regenerating context...");
const start = Date.now();
try {
await generator.generate(projectRoot);
logger.success(`Context updated in ${Date.now() - start}ms`);
} catch (error) {
logger.error(`Context regeneration failed: ${error instanceof Error ? error.message : String(error)}`);
}
}, DEBOUNCE_MS);
timers.set(watchPath, timer);
});
watchers.push(watcher);
} catch (error) {
logger.warn(`Failed to watch ${watchPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

logger.info("Watching for changes. Press Ctrl+C to stop.\n");

return () => {
logger.info("Shutting down...");
server.stop();
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
for (const watcher of watchers) watcher.close();
logger.success("Stopped.");
};
}
Also verify packages/cli/src/index.ts has signal handlers for bb dev:
typescript.action(async (projectRoot?: string) => {
const cleanup = await runDevCommand(projectRoot);
process.on("SIGINT", () => { cleanup(); process.exit(0); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
});
Without these, Ctrl+C orphans the server process and the port stays locked.
72 changes: 72 additions & 0 deletions 02_better_error_messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
Document 2: Better Error Messages
File: 02_better_error_messages.md
The goal: every error in the CLI tells the developer what went wrong AND what to do next. No raw stack traces, no generic "something failed" messages.
The pattern to follow everywhere:
typescript// BAD — raw error, no guidance
logger.error(error.message)

// GOOD — what failed + what to do
logger.error(
`Database connection failed.\n` +
`Check your DATABASE_URL in .env\n` +
`Current value: ${process.env.DATABASE_URL ?? "(not set)"}`
)
Errors to fix by command:
bb init — when dependency installation fails:
typescriptlogger.error(
`Failed to install dependencies.\n` +
`Try running manually: cd ${projectName} && bun install\n` +
`Error: ${message}`
)
bb migrate — when no schema file found:
typescriptlogger.error(
`Schema file not found: src/db/schema.ts\n` +
`Run bb migrate from your project root.\n` +
`Current directory: ${process.cwd()}`
)
bb migrate — when migration fails:
typescriptlogger.error(
`Migration failed.\n` +
`A backup was saved to: ${backupPath}\n` +
`To restore: cp ${backupPath} ${dbPath}\n` +
`Error: ${message}`
)
bb generate crud — when table not found in schema:
typescriptlogger.error(
`Table "${tableName}" not found in src/db/schema.ts\n` +
`Available tables: ${availableTables.join(", ")}\n` +
`Check the table name and try again.`
)
bb auth setup — when BetterAuth not installed:
typescriptlogger.error(
`better-auth is not installed.\n` +
`Run: bun add better-auth\n` +
`Then run bb auth setup again.`
)
bb login — when poll times out:
typescriptlogger.error(
`Authentication timed out after 5 minutes.\n` +
`Run bb login to try again.\n` +
`If the browser did not open, visit:\n ${authUrl}`
)
bb dev — when port is already in use (detect from server crash output):
typescriptlogger.error(
`Port 3000 is already in use.\n` +
`Stop the other process or change PORT in your .env file.`
)
```

**The rule: every `logger.error()` call in every command file must have three parts:**
1. What failed (specific, not generic)
2. Why it probably failed (most common cause)
3. What to do next (exact command or action)

**Files to audit and update:**
- `packages/cli/src/commands/init.ts`
- `packages/cli/src/commands/migrate.ts`
- `packages/cli/src/commands/generate.ts`
- `packages/cli/src/commands/auth.ts`
- `packages/cli/src/commands/dev.ts`
- `packages/cli/src/commands/login.ts`

---
125 changes: 125 additions & 0 deletions 03_test_suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Document 3: Test Suite Guide
**File:** `03_test_suite.md`

**Runtime: `bun:test` only. Never jest, never vitest.**

**Critical Bun 1.3.9 rules (learned the hard way — do not skip these):**
- `fs/promises access()` resolves to `null`, not `undefined` — use `existsSync()` for file checks
- `mock.module()` does NOT work for built-in Node modules
- `SchemaScanner` and `RouteScanner` take FILE PATHS, not content strings
- `ContextGenerator.generate(projectRoot)` is async, takes a directory path
- Use `port: 0` for integration tests (OS assigns a free port)
- Always pass `skipInstall: true` and `skipGit: true` to init command in tests

**Test file structure:**
```
packages/cli/test/
smoke.test.ts ← command registration only
scanner.test.ts ← SchemaScanner unit tests
route-scanner.test.ts ← RouteScanner unit tests
context-generator.test.ts ← ContextGenerator unit tests
dev.test.ts ← NEW: bb dev hot reload tests
error-messages.test.ts ← NEW: error message content tests
Template for every new feature test file:
typescriptimport { describe, it, expect, beforeAll, afterAll } from "bun:test"
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"
import { existsSync, rmSync } from "node:fs"
import os from "node:os"
import path from "node:path"

// Always use a real temp directory, never mock the filesystem
// This catches path resolution bugs that mocks hide
let tmpDir: string

beforeAll(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "betterbase-test-"))
})

afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true })
})

describe("FeatureName", () => {
it("does the thing it should do", async () => {
// Arrange: set up files in tmpDir
// Act: call the function
// Assert: check the result
})
})
Tests for bb dev hot reload (dev.test.ts):
typescriptimport { describe, it, expect } from "bun:test"
import { existsSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"
import os from "node:os"
import path from "node:path"

describe("runDevCommand", () => {
it("returns a cleanup function", async () => {
const { runDevCommand } = await import("../src/commands/dev")
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "bb-dev-test-"))

// Create minimal project structure
mkdirSync(path.join(tmpDir, "src/db"), { recursive: true })
mkdirSync(path.join(tmpDir, "src/routes"), { recursive: true })
writeFileSync(path.join(tmpDir, "src/index.ts"), `
import { Hono } from "hono"
const app = new Hono()
export default { port: 0, fetch: app.fetch }
`)
writeFileSync(path.join(tmpDir, "src/db/schema.ts"), "export const schema = {}")

const cleanup = await runDevCommand(tmpDir)
expect(typeof cleanup).toBe("function")

// Cleanup immediately — we don't want a real server running during tests
cleanup()

rmSync(tmpDir, { recursive: true, force: true })
})

it("logs an error and exits when src/index.ts is missing", async () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "bb-dev-missing-"))
// Don't create src/index.ts
// The command should call process.exit(1)
// Test this by checking the error logger was called
// (mock logger.error before calling runDevCommand)
rmSync(tmpDir, { recursive: true, force: true })
})
})
Tests for error messages (error-messages.test.ts):
typescriptimport { describe, it, expect } from "bun:test"

describe("Error message quality", () => {
it("migrate error includes backup path and restore command", () => {
// Import the error formatting function directly and assert on string content
const message = buildMigrateErrorMessage("/tmp/backup.db", "/myapp/local.db", "column not found")
expect(message).toContain("backup")
expect(message).toContain("/tmp/backup.db")
expect(message).toContain("cp ")
})

it("generate crud error lists available tables when table not found", () => {
const message = buildTableNotFoundMessage("typo_table", ["users", "posts", "comments"])
expect(message).toContain("users, posts, comments")
expect(message).toContain("typo_table")
})
})
Rule for new features: every new feature gets a test file before it ships.
The test file must cover:

The happy path (feature works correctly)
The main failure mode (what happens when input is wrong)
The cleanup path (no side effects left behind after the test)

How to run tests:
bash# All packages
bun test

# Single package
cd packages/cli && bun test

# Single file
cd packages/cli && bun test test/dev.test.ts

# With coverage
cd packages/cli && bun test --coverage
The 119 passing tests must never drop. If a new feature breaks existing tests, fix the tests or fix the feature — do not skip or comment out tests.
Loading
Loading