From e4969263921c2bed2d2456ed614da5ebbd8334ab Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 12:28:53 +0100 Subject: [PATCH 01/10] feat(db): migrations create, list, and apply Adds `bunny db migrations` for running plain SQL migration files against a Bunny Database. Files live in `migrations/` (falling back to `drizzle/`) and are named `NNNN_.sql`; the filename is the migration's identity and its numeric prefix is the apply order. Applied migrations are recorded in `__bunny_migrations`, which existing introspection excludes already, so it stays out of `db studio` and the REST layer. Each file runs through `client.migrate()` together with its tracking row, so a migration either lands and is recorded or neither happens, and foreign keys stay deferred for table rebuilds. `list` reports applied, pending, modified, and missing state without creating the tracking table. `apply` stops at the first failure and confirms only when a TTY is attached. `splitStatements` now keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact, which `db shell .sql` benefits from too. Credential resolution moves to `db/credentials.ts`, shared by shell, studio, and migrations apply instead of a third copy. --- .changeset/db-migrations.md | 6 + AGENTS.md | 72 +++- README.md | 3 + packages/cli/src/commands/db/credentials.ts | 90 +++++ packages/cli/src/commands/db/index.ts | 2 + .../cli/src/commands/db/migrations/apply.ts | 233 +++++++++++ .../src/commands/db/migrations/constants.ts | 11 + .../cli/src/commands/db/migrations/create.ts | 92 +++++ .../cli/src/commands/db/migrations/drift.ts | 33 ++ .../src/commands/db/migrations/engine.test.ts | 379 ++++++++++++++++++ .../cli/src/commands/db/migrations/engine.ts | 257 ++++++++++++ .../cli/src/commands/db/migrations/index.ts | 14 + .../cli/src/commands/db/migrations/list.ts | 161 ++++++++ packages/cli/src/commands/db/shell.ts | 96 +---- packages/cli/src/commands/db/studio.ts | 87 +--- packages/database-shell/src/parser.ts | 17 + packages/database-shell/src/shell.test.ts | 26 ++ skills/bunny-cli/SKILL.md | 3 +- skills/bunny-cli/references/database.md | 55 +++ 19 files changed, 1465 insertions(+), 172 deletions(-) create mode 100644 .changeset/db-migrations.md create mode 100644 packages/cli/src/commands/db/credentials.ts create mode 100644 packages/cli/src/commands/db/migrations/apply.ts create mode 100644 packages/cli/src/commands/db/migrations/constants.ts create mode 100644 packages/cli/src/commands/db/migrations/create.ts create mode 100644 packages/cli/src/commands/db/migrations/drift.ts create mode 100644 packages/cli/src/commands/db/migrations/engine.test.ts create mode 100644 packages/cli/src/commands/db/migrations/engine.ts create mode 100644 packages/cli/src/commands/db/migrations/index.ts create mode 100644 packages/cli/src/commands/db/migrations/list.ts diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md new file mode 100644 index 00000000..0d8f6f67 --- /dev/null +++ b/.changeset/db-migrations.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/cli": minor +"@bunny.net/database-shell": patch +--- + +feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` now keeps `CREATE TRIGGER` bodies intact diff --git a/AGENTS.md b/AGENTS.md index 7d088b91..2fb35484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,6 +279,7 @@ bunny-cli/ │ │ │ ├── create.ts # Create a new database (interactive region selection or flags) │ │ │ ├── delete.ts # Delete a database (double confirmation or --force) │ │ │ ├── docs.ts # Open database documentation in browser +│ │ │ ├── credentials.ts # Shared: resolve libSQL url + token (flags → .env → API) for shell, studio, migrations apply │ │ │ ├── link.ts # Link directory to a database (.bunny/database.json) │ │ │ ├── list.ts # List all databases │ │ │ ├── quickstart.ts # Generate quickstart guide for connecting to a database @@ -289,6 +290,14 @@ bunny-cli/ │ │ │ ├── show.ts # Show database details (regions, size, status) │ │ │ ├── studio.ts # Open a visual database explorer in the browser (local web UI) │ │ │ ├── usage.ts # Show database usage statistics +│ │ │ ├── migrations/ +│ │ │ │ ├── index.ts # defineNamespace("migrations", ...) — registers migration commands +│ │ │ │ ├── constants.ts # Default dir, drizzle fallback dir, tracking table name +│ │ │ │ ├── engine.ts # Shared: discover files, checksums, applied/pending state, apply one migration +│ │ │ │ ├── drift.ts # Shared: warn when applied migrations were edited or deleted +│ │ │ │ ├── apply.ts # Apply pending migrations in filename order +│ │ │ │ ├── create.ts # Write an empty numbered migration file +│ │ │ │ └── list.ts # Show applied/pending/modified/missing state │ │ │ ├── regions/ │ │ │ │ ├── index.ts # defineNamespace("regions", ...) — registers region commands │ │ │ │ ├── add.ts # Add primary/replica regions (interactive multiselect or flags) @@ -1050,6 +1059,13 @@ bunny │ ├── docs Open database documentation in browser │ ├── list (alias: ls) [--group-id] │ │ List all databases +│ ├── migrations Create and apply SQL migrations (files are the source of truth) +│ │ ├── apply [database-id] [--dir] [--url] [--token] [--dry-run] [--force] +│ │ │ Apply pending migrations in filename order (each file + its tracking row is one atomic batch) +│ │ ├── create (alias: new) [--dir] +│ │ │ Write an empty migrations/NNNN_.sql +│ │ └── list [database-id] (aliases: ls, status) [--dir] [--url] [--token] +│ │ Show applied / pending / modified / missing migrations │ ├── quickstart [database-id] [--lang] [--url] [--token] │ │ Generate quickstart guide for a database │ ├── regions @@ -1440,7 +1456,7 @@ The shell is split across two packages: - **Formatting** (`format.ts`) — `printResultSet()` with 5 output modes: `default`, `table`, `json`, `csv`, `markdown`. Sensitive column masking (full mask for passwords/secrets, email mask for email columns). - **Views** (`views.ts`) — Saved queries scoped per database. Stored at `~/.config/bunny/views//` (respects `XDG_CONFIG_HOME`). Callers can override via `ShellOptions.viewsDir`. - **History** (`history.ts`) — Stored at `~/.config/bunny/shell_history` (respects `XDG_CONFIG_HOME`). Max 1000 entries. -- **SQL parsing** (`parser.ts`) — `splitStatements()` for `.sql` file execution. +- **SQL parsing** (`parser.ts`) — `splitStatements()` for `.sql` file execution. Splits on `;` outside string literals, strips `--` comments (so drizzle's `--> statement-breakpoint` markers are ignored), and keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact. **Dependency injection** — The shell engine accepts a `ShellLogger` interface instead of importing the CLI logger directly: @@ -1456,7 +1472,7 @@ interface ShellLogger { **CLI wrapper** (`packages/cli/src/commands/db/shell.ts`) provides: -- Credential resolution (--url/--token flags → .env → API lookup) +- Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply` - `shellLogger()` adapter that wraps the CLI `logger` - `createClient()` call and delegation to `startShell()`/`executeQuery()`/`executeFile()` - Passes resolved `databaseId` and optional `--views-dir` to `startShell()` for saved views @@ -1481,6 +1497,58 @@ bunny db shell seed.sql --- +## Database Migrations (`bunny db migrations`) + +### Overview + +Schema changes live in plain `.sql` files that the developer writes (or generates with an ORM). The CLI's job is only to run them in order, once each, and record what it ran. There is no rollback: SQLite can't reverse most DDL, so the fix for a bad migration is another migration. + +### Convention + +- Files live in `migrations/` by default, one statement group per file, named `NNNN_.sql`. +- The **filename is the migration's identity**, and its numeric prefix is the order. Nothing else (no journal, no manifest) tracks migrations locally. +- Files are applied in lexicographic filename order, which is why prefixes are zero-padded to four digits. +- Applied migrations are recorded in `__bunny_migrations` (`id`, `name`, `checksum`, `applied_at`). The `__` prefix means `DEFAULT_EXCLUDE_PATTERNS` in `packages/database-adapter-libsql/src/introspect.ts` already hides it from `db studio` and the REST layer. + +### Engine (`packages/cli/src/commands/db/migrations/engine.ts`) + +All file and state logic is here so the commands stay thin and the logic is testable against an in-memory libSQL database (`engine.test.ts`, no network): + +- `resolveMigrationsDir(dirArg?)` — `--dir` wins; otherwise `migrations/`, falling back to `drizzle/` when `migrations/` doesn't exist (`detected: true` so the caller can say which directory it used). +- `discoverMigrations(dir)` — every `.sql` file, sorted by name. Skips dotfiles and subdirectories, so `drizzle/meta/` is ignored. +- `checksum(sql)` — sha256 of the body with CRLF normalized and edges trimmed, so reformatting line endings isn't reported as a change. +- `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)` — join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`. Both are warnings (`drift.ts`), never fatal: pending migrations still apply cleanly, and the remedy is the developer's call. +- `applyMigration(client, file)` — splits the file with `splitStatements()` and runs the statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. + +`client.migrate()` is used rather than `client.batch()` because it defers foreign key enforcement for the batch, which table rebuilds and `ALTER TABLE` need. `db shell .sql` still uses `batch()` and is not migration-aware. + +### ORM-generated migrations + +`drizzle-kit generate` (sqlite/turso dialect) writes flat `0000_.sql` files, matching this convention, so no glob or pattern config is needed. Generate with the ORM, apply with the CLI: + +```bash +drizzle-kit generate # writes drizzle/0000_curly_bat.sql +bunny db migrations apply # finds drizzle/ automatically +bunny db migrations apply --dir drizzle # or be explicit +``` + +`db migrations create` only writes top-level files; use the ORM's own generate command when an ORM owns the schema. + +### Applying + +`apply` runs pending migrations sequentially and stops at the first failure, reporting how many applied and how many are still pending. It confirms before writing when a TTY is attached, and skips the prompt under `--force` or any non-interactive run (`--output json`, no TTY) so CI and agents aren't blocked. `--dry-run` lists what would run without writing. + +```bash +bunny db migrations create add_users_table # migrations/0001_add_users_table.sql +bunny db migrations list # applied / pending / modified / missing +bunny db migrations apply --dry-run +bunny db migrations apply +``` + +`list` never creates the tracking table (it checks `sqlite_master` first), so it's safe to run against a database that has never had a migration applied. + +--- + ## Conventions for Adding New Commands 1. Create a new directory under `packages/cli/src/commands/` for the domain (e.g., `packages/cli/src/commands/deploy/`). diff --git a/README.md b/README.md index 79f9be2f..53a2b1aa 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ bun ny # Examples bun ny login bun ny db list +bun ny db migrations create add_users # write migrations/0001_add_users.sql (numeric prefix = apply order) +bun ny db migrations list # show applied / pending migrations +bun ny db migrations apply # apply pending migrations in order (--dry-run to preview, --dir drizzle for drizzle-kit output) bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy bun ny apps deploy # first run? Imports docker-compose.yml if present; otherwise auto-detects Dockerfile(s) (including monorepo subdirs) so you can pick one or many, or falls back to a pre-built image. diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts new file mode 100644 index 00000000..05a9f3a2 --- /dev/null +++ b/packages/cli/src/commands/db/credentials.ts @@ -0,0 +1,90 @@ +import { createDbClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { UserError } from "../../core/errors.ts"; +import { spinner } from "../../core/ui.ts"; +import { readEnvValue } from "../../utils/env-file.ts"; +import { generateToken, tokenExpiryFromNow } from "./api.ts"; +import { ENV_DATABASE_AUTH_TOKEN, ENV_DATABASE_URL } from "./constants.ts"; +import { resolveDbId } from "./resolve-db.ts"; + +export interface ResolvedCredentials { + url: string; + token: string; + databaseId: string | undefined; + /** True when a short-lived token was created for this run rather than read from flags or `.env`. */ + tokenGenerated: boolean; +} + +export interface ResolveCredentialsOptions { + url?: string; + token?: string; + databaseId?: string; + profile: string; + apiKey?: string; + verbose?: boolean; +} + +/** + * Resolve the database URL and auth token needed to connect over libSQL. + * + * Resolution order: + * 1. Explicit `url` / `token` (the `--url` / `--token` flags) + * 2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` + * 3. API lookup (fetches the URL and/or creates a short-lived token on the fly) + * + * Shared by `db shell`, `db studio`, and `db migrations apply`. + */ +export async function resolveCredentials( + opts: ResolveCredentialsOptions, +): Promise { + let url = opts.url ?? readEnvValue(ENV_DATABASE_URL)?.value; + let token = opts.token ?? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value; + + if (url && token) { + return { + url, + token, + databaseId: opts.databaseId, + tokenGenerated: false, + }; + } + + const config = resolveConfig(opts.profile, opts.apiKey, opts.verbose); + const apiClient = createDbClient(clientOptions(config, opts.verbose)); + + const { id: databaseId } = await resolveDbId(apiClient, opts.databaseId); + + const spin = spinner("Connecting..."); + spin.start(); + + const willGenerateToken = !token; + + const dbFetch = url + ? Promise.resolve(null) + : apiClient.GET("/v2/databases/{db_id}", { + params: { path: { db_id: databaseId } }, + }); + + if (willGenerateToken) spin.text = "Generating token..."; + + const tokenFetch = willGenerateToken + ? generateToken(apiClient, databaseId, { + authorization: "full-access", + expiresAt: tokenExpiryFromNow(), + }) + : Promise.resolve(null); + + const [dbResult, tokenResult] = await Promise.all([dbFetch, tokenFetch]); + + spin.stop(); + + if (!url && dbResult) url = dbResult.data?.db?.url; + if (willGenerateToken && tokenResult) token = tokenResult.token; + + if (!url || !token) { + throw new UserError("Could not resolve database URL or generate token."); + } + + return { url, token, databaseId, tokenGenerated: willGenerateToken }; +} diff --git a/packages/cli/src/commands/db/index.ts b/packages/cli/src/commands/db/index.ts index c7410308..57f04932 100644 --- a/packages/cli/src/commands/db/index.ts +++ b/packages/cli/src/commands/db/index.ts @@ -4,6 +4,7 @@ import { dbDeleteCommand } from "./delete.ts"; import { dbDocsCommand } from "./docs.ts"; import { dbLinkCommand } from "./link.ts"; import { dbListCommand } from "./list.ts"; +import { dbMigrationsNamespace } from "./migrations/index.ts"; import { dbQuickstartCommand } from "./quickstart.ts"; import { dbRegionsNamespace } from "./regions/index.ts"; import { dbShellCommand } from "./shell.ts"; @@ -18,6 +19,7 @@ export const dbNamespace = defineNamespace("db", "Manage databases.", [ dbDocsCommand, dbLinkCommand, dbListCommand, + dbMigrationsNamespace, dbQuickstartCommand, dbRegionsNamespace, dbShellCommand, diff --git a/packages/cli/src/commands/db/migrations/apply.ts b/packages/cli/src/commands/db/migrations/apply.ts new file mode 100644 index 00000000..569fdd2a --- /dev/null +++ b/packages/cli/src/commands/db/migrations/apply.ts @@ -0,0 +1,233 @@ +import { relative } from "node:path"; +import { defineCommand } from "../../../core/define-command.ts"; +import { errorMessage, UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; +import { ARG_DATABASE_ID, TOKEN_TTL_MINUTES } from "../constants.ts"; +import { resolveCredentials } from "../credentials.ts"; +import { ARG_DIR, MIGRATIONS_TABLE } from "./constants.ts"; +import { warnOnDrift } from "./drift.ts"; +import { + applyMigration, + discoverMigrations, + ensureMigrationsTable, + fetchApplied, + migrationStatuses, + pendingMigrations, + resolveMigrationsDir, +} from "./engine.ts"; + +const COMMAND = `apply [${ARG_DATABASE_ID}]`; +const DESCRIPTION = "Apply pending migrations to a database."; + +const ARG_URL = "url"; +const ARG_TOKEN = "token"; +const ARG_DRY_RUN = "dry-run"; +const ARG_FORCE = "force"; +const ARG_FORCE_ALIAS = "f"; + +interface ApplyArgs { + [ARG_DATABASE_ID]?: string; + [ARG_DIR]?: string; + [ARG_URL]?: string; + [ARG_TOKEN]?: string; + [ARG_DRY_RUN]?: boolean; + [ARG_FORCE]?: boolean; +} + +/** + * Apply every pending migration, in filename order. + * + * Each file runs as one atomic batch together with its tracking row, so a + * migration either lands and is recorded or neither happens. The run stops at + * the first failure and leaves the remaining migrations pending. + * + * @example + * ```bash + * bunny db migrations apply + * bunny db migrations apply --dry-run + * bunny db migrations apply --dir drizzle --force + * ``` + */ +export const dbMigrationsApplyCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 db migrations apply", "Apply all pending migrations"], + ["$0 db migrations apply --dry-run", "Show what would run without writing"], + ["$0 db migrations apply --dir drizzle", "Apply drizzle-kit output"], + ], + + builder: (yargs) => + yargs + .positional(ARG_DATABASE_ID, { + type: "string", + describe: + "Database ID (db_). Auto-detected from BUNNY_DATABASE_URL in .env if omitted.", + }) + .option(ARG_DIR, { + type: "string", + describe: "Migrations directory (default: migrations)", + }) + .option(ARG_URL, { + type: "string", + describe: "Database URL (skips API lookup)", + }) + .option(ARG_TOKEN, { + type: "string", + describe: "Auth token (skips token generation)", + }) + .option(ARG_DRY_RUN, { + type: "boolean", + default: false, + describe: "List the migrations that would run, without applying them", + }) + .option(ARG_FORCE, { + alias: ARG_FORCE_ALIAS, + type: "boolean", + default: false, + describe: "Skip confirmation prompts", + }), + + handler: async ({ + [ARG_DATABASE_ID]: databaseIdArg, + [ARG_DIR]: dirArg, + [ARG_URL]: urlArg, + [ARG_TOKEN]: tokenArg, + [ARG_DRY_RUN]: dryRun, + [ARG_FORCE]: force, + profile, + output, + verbose, + apiKey, + }) => { + const json = output === "json"; + + const { dir, detected } = resolveMigrationsDir(dirArg); + const files = discoverMigrations(dir); + const displayDir = relative(process.cwd(), dir) || "."; + + if (files.length === 0) { + throw new UserError( + `No migrations found in ${displayDir}.`, + "Run `bunny db migrations create ` to add one.", + ); + } + + if (detected && !json) logger.dim(`Using ${displayDir}`); + + const { url, token, tokenGenerated } = await resolveCredentials({ + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + }); + + if (tokenGenerated && !json) { + logger.dim( + `Session active for ${TOKEN_TTL_MINUTES} minutes. Re-run after that to reconnect.`, + ); + } + + const { createClient } = await import("@libsql/client/web"); + const client = createClient({ url, authToken: token }); + + await ensureMigrationsTable(client); + const applied = await fetchApplied(client); + const statuses = migrationStatuses(files, applied); + const pending = pendingMigrations(files, applied); + + /** `pending` is what was outstanding at the start; `done` is what actually ran. */ + const report = (done: string[]) => + logger.log( + JSON.stringify( + { + dir: displayDir, + table: MIGRATIONS_TABLE, + pending: pending.map((f) => f.name), + applied: done, + dry_run: Boolean(dryRun), + }, + null, + 2, + ), + ); + + if (pending.length === 0) { + if (json) { + report([]); + return; + } + logger.success("Already up to date."); + warnOnDrift(statuses); + return; + } + + if (!json) { + logger.log( + `${pending.length} pending migration${pending.length === 1 ? "" : "s"}:`, + ); + for (const file of pending) logger.log(` ${file.name}`); + logger.log(""); + warnOnDrift(statuses); + } + + if (dryRun) { + if (json) { + report([]); + return; + } + logger.dim("Dry run: nothing was applied."); + return; + } + + // Prompt only when a human is watching, so CI and agent runs aren't blocked. + const confirmed = await confirm("Apply now?", { + force: force || !isInteractive(output), + initial: true, + }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const done: string[] = []; + + for (const file of pending) { + const spin = spinner(`Applying ${file.name}...`); + if (!json) spin.start(); + + try { + const { statements } = await applyMigration(client, file); + spin.stop(); + done.push(file.name); + if (!json) { + logger.success( + `${file.name} (${statements} statement${statements === 1 ? "" : "s"})`, + ); + } + } catch (err: unknown) { + spin.stop(); + const remaining = pending.length - done.length - 1; + throw new UserError( + `${file.name} failed: ${errorMessage(err)}`, + done.length > 0 || remaining > 0 + ? `${done.length} applied, ${remaining} still pending. Fix ${file.name} and re-run.` + : undefined, + ); + } + } + + if (json) { + report(done); + return; + } + + logger.log(""); + logger.success( + `Applied ${done.length} migration${done.length === 1 ? "" : "s"}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/db/migrations/constants.ts b/packages/cli/src/commands/db/migrations/constants.ts new file mode 100644 index 00000000..d5fffa74 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/constants.ts @@ -0,0 +1,11 @@ +/** Default directory holding migration files, relative to the working directory. */ +export const DEFAULT_MIGRATIONS_DIR = "migrations"; + +/** Directories checked when `--dir` is omitted and the default doesn't exist. */ +export const FALLBACK_MIGRATIONS_DIRS = ["drizzle"] as const; + +/** Table recording applied migrations. The `__` prefix keeps it out of studio and REST introspection. */ +export const MIGRATIONS_TABLE = "__bunny_migrations"; + +/** Flag name for overriding the migrations directory. */ +export const ARG_DIR = "dir"; diff --git a/packages/cli/src/commands/db/migrations/create.ts b/packages/cli/src/commands/db/migrations/create.ts new file mode 100644 index 00000000..047b6150 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/create.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { ARG_DIR } from "./constants.ts"; +import { + discoverMigrations, + nextSequence, + resolveMigrationsDir, + slugify, +} from "./engine.ts"; + +const COMMAND = "create "; +const ALIASES = ["new"] as const; +const DESCRIPTION = "Create an empty migration file."; + +interface CreateArgs { + name: string; + [ARG_DIR]?: string; +} + +/** + * Create an empty, numbered migration file. + * + * The filename (`0001_add_users_table.sql`) is the migration's identity, so the + * numeric prefix determines the order `db migrations apply` runs them in. + * + * @example + * ```bash + * bunny db migrations create add_users_table + * bunny db migrations create "add users table" --dir db/migrations + * ``` + */ +export const dbMigrationsCreateCommand = defineCommand({ + command: COMMAND, + aliases: ALIASES, + describe: DESCRIPTION, + examples: [ + [ + "$0 db migrations create add_users_table", + "Create migrations/0001_add_users_table.sql", + ], + [ + "$0 db migrations create add_index --dir db/migrations", + "Use a custom directory", + ], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: "Migration name, used as the filename suffix", + demandOption: true, + }) + .option(ARG_DIR, { + type: "string", + describe: "Migrations directory (default: migrations)", + }), + + handler: async ({ name, [ARG_DIR]: dirArg, output }) => { + const { dir } = resolveMigrationsDir(dirArg); + + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const slug = slugify(name); + const sequence = nextSequence(discoverMigrations(dir)); + const filename = `${sequence}_${slug}.sql`; + const path = join(dir, filename); + + if (existsSync(path)) { + throw new UserError( + `Migration already exists: ${relative(process.cwd(), path)}`, + ); + } + + writeFileSync(path, `-- ${filename}\n`); + + const displayPath = relative(process.cwd(), path); + + if (output === "json") { + logger.log( + JSON.stringify({ name: filename, path: displayPath }, null, 2), + ); + return; + } + + logger.success(`Created ${displayPath}`); + logger.dim("Add your SQL, then run `bunny db migrations apply`."); + }, +}); diff --git a/packages/cli/src/commands/db/migrations/drift.ts b/packages/cli/src/commands/db/migrations/drift.ts new file mode 100644 index 00000000..630062e7 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/drift.ts @@ -0,0 +1,33 @@ +import { logger } from "../../../core/logger.ts"; +import type { MigrationStatus } from "./engine.ts"; + +/** + * Warn when the files on disk no longer describe what the database has applied. + * + * Both cases are reported rather than fatal: pending migrations can still be + * applied safely, and the fix (restore the file, or re-create the change as a + * new migration) is the developer's call. + */ +export function warnOnDrift(statuses: MigrationStatus[]): void { + const modified = statuses.filter((s) => s.state === "modified"); + const missing = statuses.filter((s) => s.state === "missing"); + + if (modified.length > 0) { + logger.log(""); + logger.warn( + `${modified.length} applied migration${modified.length === 1 ? " has" : "s have"} changed since being applied:`, + ); + for (const s of modified) logger.dim(` ${s.name}`); + logger.dim( + " The database was not updated. Add a new migration instead of editing an applied one.", + ); + } + + if (missing.length > 0) { + logger.log(""); + logger.warn( + `${missing.length} applied migration${missing.length === 1 ? "" : "s"} no longer exist${missing.length === 1 ? "s" : ""} on disk:`, + ); + for (const s of missing) logger.dim(` ${s.name}`); + } +} diff --git a/packages/cli/src/commands/db/migrations/engine.test.ts b/packages/cli/src/commands/db/migrations/engine.test.ts new file mode 100644 index 00000000..b2c9e24a --- /dev/null +++ b/packages/cli/src/commands/db/migrations/engine.test.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createClient } from "@libsql/client"; +import { + applyMigration, + checksum, + discoverMigrations, + ensureMigrationsTable, + fetchApplied, + type MigrationClient, + migrationStatuses, + migrationsTableExists, + nextSequence, + pendingMigrations, + resolveMigrationsDir, + slugify, +} from "./engine.ts"; + +let dir: string; + +beforeEach(() => { + // realpath so chdir-based assertions match on macOS, where /var is a symlink to /private/var. + dir = realpathSync(mkdtempSync(join(tmpdir(), "bunny-migrations-"))); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function write(name: string, sql: string) { + writeFileSync(join(dir, name), sql); +} + +function memoryClient(): MigrationClient { + return createClient({ url: ":memory:" }); +} + +describe("discoverMigrations", () => { + test("returns .sql files in filename order", () => { + write("0002_second.sql", "SELECT 2;"); + write("0001_first.sql", "SELECT 1;"); + write("0010_tenth.sql", "SELECT 10;"); + + expect(discoverMigrations(dir).map((f) => f.name)).toEqual([ + "0001_first.sql", + "0002_second.sql", + "0010_tenth.sql", + ]); + }); + + test("ignores non-sql files, dotfiles, and subdirectories", () => { + write("0001_first.sql", "SELECT 1;"); + write("README.md", "not sql"); + write(".hidden.sql", "SELECT 0;"); + mkdirSync(join(dir, "meta")); + writeFileSync(join(dir, "meta", "_journal.json"), "{}"); + + expect(discoverMigrations(dir).map((f) => f.name)).toEqual([ + "0001_first.sql", + ]); + }); + + test("throws a hinted error when the directory is missing", () => { + expect(() => discoverMigrations(join(dir, "nope"))).toThrow( + /Migrations directory not found/, + ); + }); + + test("ten or more migrations stay ordered because prefixes are zero-padded", () => { + for (let i = 1; i <= 12; i++) { + write(`${String(i).padStart(4, "0")}_m.sql`, `SELECT ${i};`); + } + + const names = discoverMigrations(dir).map((f) => f.name); + expect(names[8]).toBe("0009_m.sql"); + expect(names[9]).toBe("0010_m.sql"); + }); +}); + +describe("checksum", () => { + test("ignores line endings and trailing whitespace", () => { + expect(checksum("SELECT 1;\nSELECT 2;")).toBe( + checksum("SELECT 1;\r\nSELECT 2;\n\n"), + ); + }); + + test("changes when the SQL changes", () => { + expect(checksum("SELECT 1;")).not.toBe(checksum("SELECT 2;")); + }); +}); + +describe("nextSequence", () => { + test("starts at 0001 with no migrations", () => { + expect(nextSequence([])).toBe("0001"); + }); + + test("increments past the highest prefix, not the count", () => { + write("0001_a.sql", "SELECT 1;"); + write("0007_b.sql", "SELECT 1;"); + expect(nextSequence(discoverMigrations(dir))).toBe("0008"); + }); + + test("follows on from drizzle's zero-based numbering", () => { + write("0000_curly_bat.sql", "SELECT 1;"); + expect(nextSequence(discoverMigrations(dir))).toBe("0001"); + }); + + test("ignores files with no numeric prefix", () => { + write("init.sql", "SELECT 1;"); + expect(nextSequence(discoverMigrations(dir))).toBe("0001"); + }); +}); + +describe("slugify", () => { + test("normalizes separators and casing", () => { + expect(slugify("Add Users Table")).toBe("add_users_table"); + expect(slugify("add-users--table")).toBe("add_users_table"); + expect(slugify(" trim me ")).toBe("trim_me"); + }); + + test("rejects names with nothing usable", () => { + expect(() => slugify("---")).toThrow(/at least one letter or number/); + }); +}); + +describe("resolveMigrationsDir", () => { + const cwd = process.cwd(); + + afterEach(() => { + process.chdir(cwd); + }); + + test("an explicit dir wins", () => { + process.chdir(dir); + mkdirSync(join(dir, "migrations")); + const resolved = resolveMigrationsDir("custom"); + expect(resolved.dir).toBe(join(dir, "custom")); + expect(resolved.detected).toBe(false); + }); + + test("prefers migrations/ when it exists", () => { + process.chdir(dir); + mkdirSync(join(dir, "migrations")); + mkdirSync(join(dir, "drizzle")); + expect(resolveMigrationsDir()).toEqual({ + dir: join(dir, "migrations"), + detected: false, + }); + }); + + test("falls back to drizzle/ when migrations/ is absent", () => { + process.chdir(dir); + mkdirSync(join(dir, "drizzle")); + expect(resolveMigrationsDir()).toEqual({ + dir: join(dir, "drizzle"), + detected: true, + }); + }); + + test("returns the default when nothing exists", () => { + process.chdir(dir); + expect(resolveMigrationsDir()).toEqual({ + dir: join(dir, "migrations"), + detected: false, + }); + }); +}); + +describe("migrationStatuses", () => { + test("classifies applied, pending, modified, and missing", () => { + write("0001_applied.sql", "SELECT 1;"); + write("0002_modified.sql", "SELECT 2;"); + write("0003_pending.sql", "SELECT 3;"); + const files = discoverMigrations(dir); + + const statuses = migrationStatuses(files, [ + { + name: "0001_applied.sql", + checksum: checksum("SELECT 1;"), + applied_at: "2026-07-01 10:00:00", + }, + { + name: "0002_modified.sql", + checksum: checksum("SELECT 999;"), + applied_at: "2026-07-01 10:00:01", + }, + { + name: "0000_deleted.sql", + checksum: "abc", + applied_at: "2026-06-01 09:00:00", + }, + ]); + + expect(statuses).toEqual([ + { + name: "0001_applied.sql", + state: "applied", + appliedAt: "2026-07-01 10:00:00", + }, + { + name: "0002_modified.sql", + state: "modified", + appliedAt: "2026-07-01 10:00:01", + }, + { name: "0003_pending.sql", state: "pending" }, + { + name: "0000_deleted.sql", + state: "missing", + appliedAt: "2026-06-01 09:00:00", + }, + ]); + }); +}); + +describe("pendingMigrations", () => { + test("excludes applied files and keeps order", () => { + write("0001_a.sql", "SELECT 1;"); + write("0002_b.sql", "SELECT 2;"); + write("0003_c.sql", "SELECT 3;"); + const files = discoverMigrations(dir); + + const pending = pendingMigrations(files, [ + { name: "0002_b.sql", checksum: "x", applied_at: "now" }, + ]); + + expect(pending.map((f) => f.name)).toEqual(["0001_a.sql", "0003_c.sql"]); + }); + + test("a modified file counts as applied, not pending", () => { + write("0001_a.sql", "SELECT 1;"); + const files = discoverMigrations(dir); + + expect( + pendingMigrations(files, [ + { name: "0001_a.sql", checksum: "stale", applied_at: "now" }, + ]), + ).toEqual([]); + }); +}); + +describe("applyMigration", () => { + test("runs the statements and records the migration", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write( + "0001_users.sql", + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);\nINSERT INTO users VALUES (1, 'Ada');", + ); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + const result = await applyMigration(client, file); + expect(result.statements).toBe(2); + + const rows = await client.execute("SELECT name FROM users"); + expect(rows.rows).toHaveLength(1); + + const applied = await fetchApplied(client); + expect(applied).toHaveLength(1); + expect(applied[0]?.name).toBe("0001_users.sql"); + expect(applied[0]?.checksum).toBe(file.checksum); + expect(applied[0]?.applied_at).toBeTruthy(); + }); + + test("records nothing when a statement fails", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write( + "0001_broken.sql", + "CREATE TABLE ok (id INTEGER);\nCREATE TABLE ok (id INTEGER);", + ); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await expect(applyMigration(client, file)).rejects.toThrow(); + expect(await fetchApplied(client)).toEqual([]); + + const tables = await client.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ok'", + ); + expect(tables.rows).toHaveLength(0); + }); + + test("applying the same migration twice is rejected by the unique name", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write("0001_a.sql", "CREATE TABLE a (id INTEGER);"); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await applyMigration(client, file); + await expect(applyMigration(client, file)).rejects.toThrow(); + expect(await fetchApplied(client)).toHaveLength(1); + }); + + test("defers foreign keys so table rebuilds work", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + await client.execute("PRAGMA foreign_keys = ON"); + await client.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)"); + await client.execute( + "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))", + ); + await client.execute("INSERT INTO parent VALUES (1)"); + await client.execute("INSERT INTO child VALUES (1, 1)"); + + write( + "0001_rebuild.sql", + [ + "CREATE TABLE parent_new (id INTEGER PRIMARY KEY, label TEXT);", + "INSERT INTO parent_new (id) SELECT id FROM parent;", + "DROP TABLE parent;", + "ALTER TABLE parent_new RENAME TO parent;", + ].join("\n"), + ); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await applyMigration(client, file); + + const cols = await client.execute("SELECT label FROM parent WHERE id = 1"); + expect(cols.rows).toHaveLength(1); + }); + + test("rejects a file with no statements", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write("0001_empty.sql", "-- nothing to do\n"); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await expect(applyMigration(client, file)).rejects.toThrow( + /No SQL statements found/, + ); + }); +}); + +describe("migrationsTableExists", () => { + test("false before the table is created, true after", async () => { + const client = memoryClient(); + expect(await migrationsTableExists(client)).toBe(false); + await ensureMigrationsTable(client); + expect(await migrationsTableExists(client)).toBe(true); + }); +}); + +describe("ensureMigrationsTable", () => { + test("is idempotent and preserves rows", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write("0001_a.sql", "CREATE TABLE a (id INTEGER);"); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + await applyMigration(client, file); + + await ensureMigrationsTable(client); + expect(await fetchApplied(client)).toHaveLength(1); + }); + + test("refuses a table name that isn't a bare identifier", async () => { + const client = memoryClient(); + await expect( + ensureMigrationsTable(client, 'x"; DROP TABLE users; --'), + ).rejects.toThrow(/Invalid table name/); + }); +}); diff --git a/packages/cli/src/commands/db/migrations/engine.ts b/packages/cli/src/commands/db/migrations/engine.ts new file mode 100644 index 00000000..0c9e4b14 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/engine.ts @@ -0,0 +1,257 @@ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { splitStatements } from "@bunny.net/database-shell"; +import type { Client } from "@libsql/client"; +import { UserError } from "../../../core/errors.ts"; +import { + DEFAULT_MIGRATIONS_DIR, + FALLBACK_MIGRATIONS_DIRS, + MIGRATIONS_TABLE, +} from "./constants.ts"; + +/** The libSQL client surface the engine needs, so tests can pass an in-memory client. */ +export type MigrationClient = Pick; + +export interface MigrationFile { + /** Filename including the `.sql` extension, e.g. `0001_add_users.sql`. */ + name: string; + path: string; + sql: string; + checksum: string; +} + +export interface AppliedMigration { + name: string; + checksum: string; + applied_at: string; +} + +export type MigrationState = "applied" | "pending" | "modified" | "missing"; + +export interface MigrationStatus { + name: string; + state: MigrationState; + /** Set for every state except `pending`. */ + appliedAt?: string; +} + +/** Only bare identifiers are safe to interpolate into SQL, so refuse anything else. */ +function quoteIdentifier(name: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new UserError(`Invalid table name: ${name}`); + } + return `"${name}"`; +} + +/** Hash of the migration body, normalized so line endings and trailing whitespace don't count as a change. */ +export function checksum(sql: string): string { + const normalized = sql.replace(/\r\n/g, "\n").trim(); + return createHash("sha256").update(normalized).digest("hex"); +} + +/** + * Pick the migrations directory. + * + * An explicit `--dir` always wins. Otherwise `migrations/` is used, falling back + * to a known ORM output directory (`drizzle/`) when `migrations/` doesn't exist, + * so `drizzle-kit generate` output works without configuration. + */ +export function resolveMigrationsDir(dirArg?: string): { + dir: string; + detected: boolean; +} { + if (dirArg) return { dir: resolve(dirArg), detected: false }; + + if (isDirectory(DEFAULT_MIGRATIONS_DIR)) { + return { dir: resolve(DEFAULT_MIGRATIONS_DIR), detected: false }; + } + + for (const candidate of FALLBACK_MIGRATIONS_DIRS) { + if (isDirectory(candidate)) { + return { dir: resolve(candidate), detected: true }; + } + } + + return { dir: resolve(DEFAULT_MIGRATIONS_DIR), detected: false }; +} + +function isDirectory(path: string): boolean { + return existsSync(path) && statSync(path).isDirectory(); +} + +/** + * Read every `.sql` file in `dir`, sorted by filename. + * + * Filenames are the migration identity, so the numeric prefix written by + * `db migrations create` (and by `drizzle-kit generate`) determines order. + * Subdirectories are ignored, which skips `drizzle/meta/`. + */ +export function discoverMigrations(dir: string): MigrationFile[] { + if (!isDirectory(dir)) { + throw new UserError( + `Migrations directory not found: ${dir}`, + "Run `bunny db migrations create ` to create your first migration.", + ); + } + + const files: MigrationFile[] = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (entry.name.startsWith(".")) continue; + if (!entry.name.endsWith(".sql")) continue; + + const path = join(dir, entry.name); + const sql = readFileSync(path, "utf-8"); + files.push({ name: entry.name, path, sql, checksum: checksum(sql) }); + } + + return files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +/** Next zero-padded sequence number, one above the highest numeric prefix present. */ +export function nextSequence(files: MigrationFile[]): string { + let highest = 0; + for (const file of files) { + const match = /^(\d+)/.exec(file.name); + if (!match?.[1]) continue; + highest = Math.max(highest, Number.parseInt(match[1], 10)); + } + return String(highest + 1).padStart(4, "0"); +} + +/** Normalize a user-supplied migration name into a filename-safe slug. */ +export function slugify(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + + if (!slug) { + throw new UserError( + `Migration name must contain at least one letter or number: ${name}`, + ); + } + + return slug; +} + +/** Create the tracking table if it isn't there yet. */ +export async function ensureMigrationsTable( + client: MigrationClient, + table = MIGRATIONS_TABLE, +): Promise { + await client.execute( + `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(table)} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + ); +} + +/** True when the tracking table is present, so read-only commands don't have to create it. */ +export async function migrationsTableExists( + client: MigrationClient, + table = MIGRATIONS_TABLE, +): Promise { + const result = await client.execute({ + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + args: [table], + }); + return result.rows.length > 0; +} + +/** Read the applied migrations, oldest first. Assumes the table exists. */ +export async function fetchApplied( + client: MigrationClient, + table = MIGRATIONS_TABLE, +): Promise { + const result = await client.execute( + `SELECT name, checksum, applied_at FROM ${quoteIdentifier(table)} ORDER BY id`, + ); + + return (result.rows as unknown as AppliedMigration[]).map((row) => ({ + name: String(row.name), + checksum: String(row.checksum), + applied_at: String(row.applied_at), + })); +} + +/** + * Join the files on disk with what the database has recorded. + * + * A file whose checksum no longer matches the recorded one is `modified`; a + * recorded migration with no matching file is `missing`. Both mean the local + * migrations no longer describe the database, so callers surface them. + */ +export function migrationStatuses( + files: MigrationFile[], + applied: AppliedMigration[], +): MigrationStatus[] { + const byName = new Map(applied.map((row) => [row.name, row])); + + const statuses: MigrationStatus[] = files.map((file) => { + const record = byName.get(file.name); + if (!record) return { name: file.name, state: "pending" }; + return { + name: file.name, + state: record.checksum === file.checksum ? "applied" : "modified", + appliedAt: record.applied_at, + }; + }); + + const onDisk = new Set(files.map((file) => file.name)); + for (const record of applied) { + if (onDisk.has(record.name)) continue; + statuses.push({ + name: record.name, + state: "missing", + appliedAt: record.applied_at, + }); + } + + return statuses; +} + +/** Files that haven't been applied yet, in filename order. */ +export function pendingMigrations( + files: MigrationFile[], + applied: AppliedMigration[], +): MigrationFile[] { + const byName = new Set(applied.map((row) => row.name)); + return files.filter((file) => !byName.has(file.name)); +} + +/** + * Apply one migration. + * + * Uses `migrate()` rather than `batch()` so foreign keys are deferred for the + * duration, which table rebuilds and `ALTER TABLE` need. The tracking row is + * part of the same batch, so a migration either lands and is recorded or + * neither happens. + */ +export async function applyMigration( + client: MigrationClient, + file: MigrationFile, + table = MIGRATIONS_TABLE, +): Promise<{ statements: number }> { + const statements = splitStatements(file.sql); + + if (statements.length === 0) { + throw new UserError(`No SQL statements found in ${file.name}.`); + } + + await client.migrate([ + ...statements.map((sql) => ({ sql })), + { + sql: `INSERT INTO ${quoteIdentifier(table)} (name, checksum) VALUES (?, ?)`, + args: [file.name, file.checksum], + }, + ]); + + return { statements: statements.length }; +} diff --git a/packages/cli/src/commands/db/migrations/index.ts b/packages/cli/src/commands/db/migrations/index.ts new file mode 100644 index 00000000..621fb8df --- /dev/null +++ b/packages/cli/src/commands/db/migrations/index.ts @@ -0,0 +1,14 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dbMigrationsApplyCommand } from "./apply.ts"; +import { dbMigrationsCreateCommand } from "./create.ts"; +import { dbMigrationsListCommand } from "./list.ts"; + +export const dbMigrationsNamespace = defineNamespace( + "migrations", + "Create and apply SQL migrations.", + [ + dbMigrationsApplyCommand, + dbMigrationsCreateCommand, + dbMigrationsListCommand, + ], +); diff --git a/packages/cli/src/commands/db/migrations/list.ts b/packages/cli/src/commands/db/migrations/list.ts new file mode 100644 index 00000000..15bbad48 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/list.ts @@ -0,0 +1,161 @@ +import { relative } from "node:path"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { ARG_DATABASE_ID } from "../constants.ts"; +import { resolveCredentials } from "../credentials.ts"; +import { ARG_DIR, MIGRATIONS_TABLE } from "./constants.ts"; +import { warnOnDrift } from "./drift.ts"; +import { + type AppliedMigration, + discoverMigrations, + fetchApplied, + migrationStatuses, + migrationsTableExists, + resolveMigrationsDir, +} from "./engine.ts"; + +const COMMAND = `list [${ARG_DATABASE_ID}]`; +const ALIASES = ["ls", "status"] as const; +const DESCRIPTION = "Show which migrations have been applied."; + +const ARG_URL = "url"; +const ARG_TOKEN = "token"; + +const STATE_LABELS = { + applied: "Applied", + pending: "Pending", + modified: "Modified", + missing: "Missing", +} as const; + +interface ListArgs { + [ARG_DATABASE_ID]?: string; + [ARG_DIR]?: string; + [ARG_URL]?: string; + [ARG_TOKEN]?: string; +} + +/** + * Compare the migration files on disk against what the database has recorded. + * + * @example + * ```bash + * bunny db migrations list + * bunny db migrations list --output json + * ``` + */ +export const dbMigrationsListCommand = defineCommand({ + command: COMMAND, + aliases: ALIASES, + describe: DESCRIPTION, + examples: [ + ["$0 db migrations list", "Show applied and pending migrations"], + ["$0 db migrations list --output json", "JSON output for scripting"], + ], + + builder: (yargs) => + yargs + .positional(ARG_DATABASE_ID, { + type: "string", + describe: + "Database ID (db_). Auto-detected from BUNNY_DATABASE_URL in .env if omitted.", + }) + .option(ARG_DIR, { + type: "string", + describe: "Migrations directory (default: migrations)", + }) + .option(ARG_URL, { + type: "string", + describe: "Database URL (skips API lookup)", + }) + .option(ARG_TOKEN, { + type: "string", + describe: "Auth token (skips token generation)", + }), + + handler: async ({ + [ARG_DATABASE_ID]: databaseIdArg, + [ARG_DIR]: dirArg, + [ARG_URL]: urlArg, + [ARG_TOKEN]: tokenArg, + profile, + output, + verbose, + apiKey, + }) => { + const { dir, detected } = resolveMigrationsDir(dirArg); + const files = discoverMigrations(dir); + const displayDir = relative(process.cwd(), dir) || "."; + + if (detected && output !== "json") { + logger.dim(`Using ${displayDir}`); + } + + const { url, token } = await resolveCredentials({ + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + }); + + const { createClient } = await import("@libsql/client/web"); + const client = createClient({ url, authToken: token }); + + // Don't create the tracking table from a read-only command. + const applied: AppliedMigration[] = (await migrationsTableExists(client)) + ? await fetchApplied(client) + : []; + + const statuses = migrationStatuses(files, applied); + + if (output === "json") { + logger.log( + JSON.stringify( + { + dir: displayDir, + table: MIGRATIONS_TABLE, + migrations: statuses.map((s) => ({ + name: s.name, + state: s.state, + applied_at: s.appliedAt ?? null, + })), + }, + null, + 2, + ), + ); + return; + } + + if (statuses.length === 0) { + logger.info(`No migrations found in ${displayDir}.`); + logger.dim("Run `bunny db migrations create ` to add one."); + return; + } + + logger.log( + formatTable( + ["Migration", "State", "Applied"], + statuses.map((s) => [ + s.name, + STATE_LABELS[s.state], + s.appliedAt ?? "-", + ]), + output, + ), + ); + + const pending = statuses.filter((s) => s.state === "pending").length; + logger.log(""); + logger.dim( + pending === 0 + ? "Up to date." + : `${pending} pending. Run \`bunny db migrations apply\` to apply ${pending === 1 ? "it" : "them"}.`, + ); + + warnOnDrift(statuses); + }, +}); diff --git a/packages/cli/src/commands/db/shell.ts b/packages/cli/src/commands/db/shell.ts index 9bcbf659..06bbd24d 100644 --- a/packages/cli/src/commands/db/shell.ts +++ b/packages/cli/src/commands/db/shell.ts @@ -1,22 +1,11 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { PrintMode, ShellLogger } from "@bunny.net/database-shell"; -import { createDbClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { readEnvValue } from "../../utils/env-file.ts"; -import { generateToken, tokenExpiryFromNow } from "./api.ts"; -import { - ARG_DATABASE_ID, - ENV_DATABASE_AUTH_TOKEN, - ENV_DATABASE_URL, - TOKEN_TTL_MINUTES, -} from "./constants.ts"; -import { resolveDbId } from "./resolve-db.ts"; +import { ARG_DATABASE_ID, TOKEN_TTL_MINUTES } from "./constants.ts"; +import { resolveCredentials } from "./credentials.ts"; const COMMAND = `shell [${ARG_DATABASE_ID}] [query]`; const DESCRIPTION = "Open an interactive SQL shell for a database."; @@ -43,79 +32,6 @@ function shellLogger(): ShellLogger { }; } -/** - * Resolve the database URL and auth token needed to connect. - * - * Resolution order: - * 1. Explicit `--url` / `--token` flags - * 2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` - * 3. API lookup (fetches the URL and/or generates a token on the fly) - */ -async function resolveCredentials( - urlArg: string | undefined, - tokenArg: string | undefined, - databaseIdArg: string | undefined, - profile: string, - apiKeyOverride?: string, - verbose = false, -): Promise<{ - url: string; - token: string; - databaseId: string | undefined; - tokenGenerated: boolean; -}> { - let url = urlArg ?? readEnvValue(ENV_DATABASE_URL)?.value; - let token = tokenArg ?? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value; - - if (url && token) { - return { url, token, databaseId: databaseIdArg, tokenGenerated: false }; - } - - const config = resolveConfig(profile, apiKeyOverride, verbose); - const apiClient = createDbClient(clientOptions(config, verbose)); - - const { id: databaseId } = await resolveDbId(apiClient, databaseIdArg); - - const spin = spinner("Connecting..."); - spin.start(); - - const fetches: Promise[] = []; - const willGenerateToken = !token; - - if (!url) { - fetches.push( - apiClient.GET("/v2/databases/{db_id}", { - params: { path: { db_id: databaseId } }, - }), - ); - } else { - fetches.push(Promise.resolve(null)); - } - - if (willGenerateToken) { - spin.text = "Generating token..."; - fetches.push( - generateToken(apiClient, databaseId, { - authorization: "full-access", - expiresAt: tokenExpiryFromNow(), - }), - ); - } - - const [dbResult, tokenResult] = await Promise.all(fetches); - - spin.stop(); - - if (!url && dbResult) url = dbResult.data?.db?.url; - if (willGenerateToken && tokenResult) token = tokenResult.token; - - if (!url || !token) { - throw new UserError("Could not resolve database URL or generate token."); - } - - return { url, token, databaseId, tokenGenerated: willGenerateToken }; -} - export const dbShellCommand = defineCommand<{ [ARG_DATABASE_ID]?: string; query?: string; @@ -215,14 +131,14 @@ export const dbShellCommand = defineCommand<{ token, databaseId: resolvedDbId, tokenGenerated, - } = await resolveCredentials( - urlArg, - tokenArg, + } = await resolveCredentials({ + url: urlArg, + token: tokenArg, databaseId, profile, apiKey, verbose, - ); + }); if (tokenGenerated && output !== "json" && modeArg !== "json") { logger.dim( diff --git a/packages/cli/src/commands/db/studio.ts b/packages/cli/src/commands/db/studio.ts index ea571fb9..ecd55410 100644 --- a/packages/cli/src/commands/db/studio.ts +++ b/packages/cli/src/commands/db/studio.ts @@ -1,19 +1,8 @@ -import { createDbClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; -import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, spinner } from "../../core/ui.ts"; -import { readEnvValue } from "../../utils/env-file.ts"; -import { generateToken, tokenExpiryFromNow } from "./api.ts"; -import { - ARG_DATABASE_ID, - ENV_DATABASE_AUTH_TOKEN, - ENV_DATABASE_URL, - TOKEN_TTL_MINUTES, -} from "./constants.ts"; -import { resolveDbId } from "./resolve-db.ts"; +import { confirm } from "../../core/ui.ts"; +import { ARG_DATABASE_ID, TOKEN_TTL_MINUTES } from "./constants.ts"; +import { resolveCredentials } from "./credentials.ts"; const COMMAND = `studio [${ARG_DATABASE_ID}]`; const DESCRIPTION = "Open a visual database explorer in your browser."; @@ -26,66 +15,6 @@ const ARG_DEV = "dev"; const ARG_FORCE = "force"; const ARG_FORCE_ALIAS = "f"; -/** - * Resolve database credentials — same pattern as shell.ts. - */ -async function resolveCredentials( - urlArg: string | undefined, - tokenArg: string | undefined, - databaseIdArg: string | undefined, - profile: string, - apiKeyOverride?: string, - verbose = false, -): Promise<{ url: string; token: string; databaseId: string | undefined }> { - let url = urlArg ?? readEnvValue(ENV_DATABASE_URL)?.value; - let token = tokenArg ?? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value; - - if (url && token) return { url, token, databaseId: databaseIdArg }; - - const config = resolveConfig(profile, apiKeyOverride, verbose); - const apiClient = createDbClient(clientOptions(config, verbose)); - - const { id: databaseId } = await resolveDbId(apiClient, databaseIdArg); - - const spin = spinner("Connecting..."); - spin.start(); - - const fetches: Promise[] = []; - - if (!url) { - fetches.push( - apiClient.GET("/v2/databases/{db_id}", { - params: { path: { db_id: databaseId } }, - }), - ); - } else { - fetches.push(Promise.resolve(null)); - } - - if (!token) { - spin.text = "Generating token..."; - fetches.push( - generateToken(apiClient, databaseId, { - authorization: "full-access", - expiresAt: tokenExpiryFromNow(), - }), - ); - } - - const [dbResult, tokenResult] = await Promise.all(fetches); - - spin.stop(); - - if (!url && dbResult) url = dbResult.data?.db?.url; - if (!token && tokenResult) token = tokenResult.token; - - if (!url || !token) { - throw new UserError("Could not resolve database URL or generate token."); - } - - return { url, token, databaseId }; -} - export const dbStudioCommand = defineCommand<{ [ARG_DATABASE_ID]?: string; [ARG_PORT]?: number; @@ -174,14 +103,14 @@ export const dbStudioCommand = defineCommand<{ const { createClient } = await import("@libsql/client/web"); const { startStudio } = await import("@bunny.net/database-studio"); - const { url, token } = await resolveCredentials( - urlArg, - tokenArg, - databaseIdArg, + const { url, token } = await resolveCredentials({ + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, profile, apiKey, verbose, - ); + }); const client = createClient({ url, authToken: token }); diff --git a/packages/database-shell/src/parser.ts b/packages/database-shell/src/parser.ts index 2d4644fd..426db058 100644 --- a/packages/database-shell/src/parser.ts +++ b/packages/database-shell/src/parser.ts @@ -1,6 +1,19 @@ +/** Statements whose body is a `BEGIN ... END` block, so inner semicolons don't terminate them. */ +const BLOCK_BODY_START = /^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i; + +/** True when `current` opens a `BEGIN ... END` block that hasn't been closed yet. */ +function inBlockBody(current: string): boolean { + const trimmed = current.trim(); + if (!BLOCK_BODY_START.test(trimmed)) return false; + return !/\bEND$/i.test(trimmed); +} + /** * Split a SQL string into individual statements, handling single-quoted strings * and `--` line comments. Trims whitespace and filters empty results. + * + * `CREATE TRIGGER` bodies are kept intact: semicolons inside `BEGIN ... END` + * don't split the statement. */ export function splitStatements(sql: string): string[] { const statements: string[] = []; @@ -37,6 +50,10 @@ export function splitStatements(sql: string): string[] { } if (ch === ";" && !inString) { + if (inBlockBody(current)) { + current += ch; + continue; + } const trimmed = current.trim(); if (trimmed.length > 0) statements.push(trimmed); current = ""; diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index 8d812772..d675a4da 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -571,6 +571,32 @@ describe("splitStatements", () => { const sql = "-- this; is a comment\nSELECT 1;"; expect(splitStatements(sql)).toEqual(["SELECT 1"]); }); + + test("keeps a CREATE TRIGGER body intact", () => { + const sql = + "CREATE TRIGGER touch AFTER UPDATE ON users BEGIN\n UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;\nEND;"; + expect(splitStatements(sql)).toEqual([ + "CREATE TRIGGER touch AFTER UPDATE ON users BEGIN\n UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;\nEND", + ]); + }); + + test("keeps a multi-statement trigger body intact and splits what follows", () => { + const sql = + "CREATE TEMPORARY TRIGGER log AFTER INSERT ON t BEGIN\n INSERT INTO audit VALUES (1);\n INSERT INTO audit VALUES (2);\nEND;\nSELECT 1;"; + expect(splitStatements(sql)).toEqual([ + "CREATE TEMPORARY TRIGGER log AFTER INSERT ON t BEGIN\n INSERT INTO audit VALUES (1);\n INSERT INTO audit VALUES (2);\nEND", + "SELECT 1", + ]); + }); + + test("splits drizzle statement-breakpoint files", () => { + const sql = + "CREATE TABLE `users` (\n\t`id` integer PRIMARY KEY NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `users_id` ON `users` (`id`);"; + expect(splitStatements(sql)).toEqual([ + "CREATE TABLE `users` (\n\t`id` integer PRIMARY KEY NOT NULL\n)", + "CREATE UNIQUE INDEX `users_id` ON `users` (`id`)", + ]); + }); }); describe("views", () => { diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index a5483b06..ab7d0b49 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -34,6 +34,7 @@ bunny api GET /user bunny db create bunny db list bunny db shell +bunny db migrations apply # run pending migrations/*.sql files # manage Edge Scripts bunny scripts init @@ -63,7 +64,7 @@ bunny sites deployments publish --previous --force # instant rollback Use this to route to the correct reference file: - **Authenticate or switch profiles** -> `references/auth.md` -- **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` +- **Database management (create, list, show, link, delete, shell, studio, migrations, regions, tokens)** -> `references/database.md` - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` - **Static sites (create, deploy, rollback, previews, custom domains)** -> `references/sites.md` diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index be9bc6e9..9ed23b5d 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -204,6 +204,61 @@ Spins up a local server, generates a short-lived auth token if needed, and opens --- +## `bunny db migrations` — Create and apply SQL migrations + +Migrations are plain `.sql` files in `migrations/`, named `NNNN_.sql`. The filename is the migration's identity and its numeric prefix is the apply order. Applied migrations are recorded in a `__bunny_migrations` table in the database. There is no rollback: fix a bad migration with another migration. + +```bash +bunny db migrations create add_users_table # writes migrations/0001_add_users_table.sql +bunny db migrations list # applied / pending / modified / missing +bunny db migrations apply --dry-run # show what would run +bunny db migrations apply # apply pending migrations in order +bunny db migrations apply --dir drizzle # apply drizzle-kit generate output +``` + +### `bunny db migrations create ` (alias: `new`) + +| Flag | Default | Description | +| ------- | ------------ | -------------------- | +| `--dir` | `migrations` | Migrations directory | + +Numbers the file one above the highest existing prefix and slugifies the name. Creates the directory if needed. + +### `bunny db migrations list` (aliases: `ls`, `status`) + +| Flag | Default | Description | +| --------- | ------------ | ----------------------------------- | +| `--dir` | `migrations` | Migrations directory | +| `--url` | | Database URL (skips API lookup) | +| `--token` | | Auth token (skips token generation) | + +Never creates the tracking table, so it is safe against a database that has never had a migration applied. States are `Applied`, `Pending`, `Modified` (the file changed after being applied), and `Missing` (the file was deleted). Modified and missing are warnings, not errors. + +### `bunny db migrations apply` + +| Flag | Short | Default | Description | +| ----------- | ----- | ------------ | ------------------------------------ | +| `--dir` | | `migrations` | Migrations directory | +| `--dry-run` | | `false` | List what would run without applying | +| `--force` | `-f` | `false` | Skip the confirmation prompt | +| `--url` | | | Database URL (skips API lookup) | +| `--token` | | | Auth token (skips token generation) | + +Each file runs as one atomic batch together with its tracking row, so a migration either lands and is recorded or neither happens. Foreign keys are deferred for the duration, so table rebuilds and `ALTER TABLE` work. The run stops at the first failure and leaves the rest pending. + +Confirms before writing when a TTY is attached; the prompt is skipped under `--force`, `--output json`, or any non-interactive run, so CI and agent flows aren't blocked. Credential resolution mirrors `db shell`. + +### ORM-generated migrations + +`drizzle-kit generate` writes flat `0000_.sql` files, which match this convention. When `migrations/` doesn't exist, `drizzle/` is used automatically. Generate with the ORM, apply with the CLI: + +```bash +drizzle-kit generate +bunny db migrations apply +``` + +--- + ## `bunny db quickstart` — Language-specific getting-started guide ```bash From e114a6d4b9207a07ee149ab1ff09988ce2f9d751 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 12:55:49 +0100 Subject: [PATCH 02/10] fix(db): address review feedback on migrations Parser: a trigger body statement ending in `CASE ... END;` was mistaken for the trigger's own terminator, shredding a valid trigger into three fragments. Nesting is now counted across `BEGIN` and `CASE` openers, with quoted strings and identifiers scrubbed first so a column named `end` doesn't skew the count. Credentials: an explicit database ID no longer falls through to `.env`, which could target a different database than the one named on the command line. A generated token is now bound to the resolved database's host, so `--url` without `--token` is refused on mismatch instead of sending a full-access token to an unverified endpoint, and the check runs before the token is created. Apply: `ensureMigrationsTable()` moved after the dry-run exit and the confirmation, so a preview or a declined run writes nothing. The failed migration is now counted as still pending, since its tracking row rolls back with it. Also wraps the pre-confirmation read in `readApplied()`, turning a bad URL or token into a hinted error instead of an unexpected-error exit. --- .changeset/db-migrations.md | 2 +- AGENTS.md | 7 +- .../cli/src/commands/db/credentials.test.ts | 46 ++++++++++ packages/cli/src/commands/db/credentials.ts | 90 ++++++++++++++----- .../cli/src/commands/db/migrations/apply.ts | 15 ++-- .../src/commands/db/migrations/engine.test.ts | 22 +++++ .../cli/src/commands/db/migrations/engine.ts | 25 +++++- .../cli/src/commands/db/migrations/list.ts | 8 +- packages/database-shell/src/parser.ts | 19 +++- packages/database-shell/src/shell.test.ts | 21 +++++ skills/bunny-cli/references/database.md | 5 ++ 11 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/commands/db/credentials.test.ts diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md index 0d8f6f67..a8d3648d 100644 --- a/.changeset/db-migrations.md +++ b/.changeset/db-migrations.md @@ -3,4 +3,4 @@ "@bunny.net/database-shell": patch --- -feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` now keeps `CREATE TRIGGER` bodies intact +feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` keeps `CREATE TRIGGER` bodies intact; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a generated token to a `--url` on a different host diff --git a/AGENTS.md b/AGENTS.md index 2fb35484..bc33de35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1472,7 +1472,7 @@ interface ShellLogger { **CLI wrapper** (`packages/cli/src/commands/db/shell.ts`) provides: -- Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply` +- Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply`. Two safety rules live there: an explicit database ID skips `.env` entirely (it may describe a different database, and silently connecting there would target the wrong one), and a generated token is only sent to a URL whose host matches that database's canonical URL, so `--url` without `--token` is rejected on mismatch rather than handing a full-access token to an unverified host. - `shellLogger()` adapter that wraps the CLI `logger` - `createClient()` call and delegation to `startShell()`/`executeQuery()`/`executeFile()` - Passes resolved `databaseId` and optional `--views-dir` to `startShell()` for saved views @@ -1519,6 +1519,7 @@ All file and state logic is here so the commands stay thin and the logic is test - `checksum(sql)` — sha256 of the body with CRLF normalized and edges trimmed, so reformatting line endings isn't reported as a change. - `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)` — join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`. Both are warnings (`drift.ts`), never fatal: pending migrations still apply cleanly, and the remedy is the developer's call. - `applyMigration(client, file)` — splits the file with `splitStatements()` and runs the statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. +- `readApplied(client)` — the read path for `list` and for `apply` before confirmation. Checks `sqlite_master` rather than creating the tracking table, so a preview never writes, and converts connection or query failures into a hinted `UserError` instead of an unexpected-error exit. `client.migrate()` is used rather than `client.batch()` because it defers foreign key enforcement for the batch, which table rebuilds and `ALTER TABLE` need. `db shell .sql` still uses `batch()` and is not migration-aware. @@ -1536,7 +1537,9 @@ bunny db migrations apply --dir drizzle # or be explicit ### Applying -`apply` runs pending migrations sequentially and stops at the first failure, reporting how many applied and how many are still pending. It confirms before writing when a TTY is attached, and skips the prompt under `--force` or any non-interactive run (`--output json`, no TTY) so CI and agents aren't blocked. `--dry-run` lists what would run without writing. +`apply` runs pending migrations sequentially and stops at the first failure, reporting how many applied and how many are still pending (the failed file counts as pending, since its tracking row rolled back with it). It confirms before writing when a TTY is attached, and skips the prompt under `--force` or any non-interactive run (`--output json`, no TTY) so CI and agents aren't blocked. `--dry-run` lists what would run without writing. + +Nothing is written before confirmation, including the tracking table: `ensureMigrationsTable()` runs only after the confirm and after the `--dry-run` exit, so a preview against read-only credentials lists pending files instead of failing on a schema write. ```bash bunny db migrations create add_users_table # migrations/0001_add_users_table.sql diff --git a/packages/cli/src/commands/db/credentials.test.ts b/packages/cli/src/commands/db/credentials.test.ts new file mode 100644 index 00000000..c862be61 --- /dev/null +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { sameHost } from "./credentials.ts"; + +const CANONICAL = "libsql://my-db-abc.lite.bunnydb.net/"; + +describe("sameHost", () => { + test("accepts the canonical URL with or without a trailing slash", () => { + expect(sameHost("libsql://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( + true, + ); + expect(sameHost(CANONICAL, CANONICAL)).toBe(true); + }); + + test("accepts https for the same host, since libsql maps onto it", () => { + expect(sameHost("https://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( + true, + ); + }); + + test("ignores host casing and path", () => { + expect( + sameHost("libsql://MY-DB-ABC.lite.bunnydb.net/anything", CANONICAL), + ).toBe(true); + }); + + test("rejects a different database on the same domain", () => { + expect(sameHost("libsql://other-db-xyz.lite.bunnydb.net", CANONICAL)).toBe( + false, + ); + }); + + test("rejects a foreign host", () => { + expect(sameHost("libsql://evil.example.com", CANONICAL)).toBe(false); + }); + + test("rejects a host that only prefixes the canonical one", () => { + expect( + sameHost("libsql://my-db-abc.lite.bunnydb.net.example.com", CANONICAL), + ).toBe(false); + }); + + test("rejects unparseable input rather than treating it as a match", () => { + expect(sameHost("my-db-abc.lite.bunnydb.net", CANONICAL)).toBe(false); + expect(sameHost("", CANONICAL)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index 05a9f3a2..de9c93ff 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -25,6 +25,17 @@ export interface ResolveCredentialsOptions { verbose?: boolean; } +/** Same host, ignoring scheme, port, and path, since `libsql://` and `https://` address the same endpoint. */ +export function sameHost(a: string, b: string): boolean { + try { + return ( + new URL(a).hostname.toLowerCase() === new URL(b).hostname.toLowerCase() + ); + } catch { + return false; + } +} + /** * Resolve the database URL and auth token needed to connect over libSQL. * @@ -33,13 +44,24 @@ export interface ResolveCredentialsOptions { * 2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` * 3. API lookup (fetches the URL and/or creates a short-lived token on the fly) * + * An explicit database ID skips step 2 entirely: `.env` may describe a different + * database, and silently connecting there would target the wrong database. + * + * A generated token is only ever sent to a URL that belongs to the database it + * was created for, so overriding `--url` without `--token` is rejected rather + * than handing a full-access token to an unverified host. + * * Shared by `db shell`, `db studio`, and `db migrations apply`. */ export async function resolveCredentials( opts: ResolveCredentialsOptions, ): Promise { - let url = opts.url ?? readEnvValue(ENV_DATABASE_URL)?.value; - let token = opts.token ?? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value; + const useEnv = !opts.databaseId; + let url = + opts.url ?? (useEnv ? readEnvValue(ENV_DATABASE_URL)?.value : undefined); + let token = + opts.token ?? + (useEnv ? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value : undefined); if (url && token) { return { @@ -60,27 +82,49 @@ export async function resolveCredentials( const willGenerateToken = !token; - const dbFetch = url - ? Promise.resolve(null) - : apiClient.GET("/v2/databases/{db_id}", { - params: { path: { db_id: databaseId } }, - }); - - if (willGenerateToken) spin.text = "Generating token..."; - - const tokenFetch = willGenerateToken - ? generateToken(apiClient, databaseId, { - authorization: "full-access", - expiresAt: tokenExpiryFromNow(), - }) - : Promise.resolve(null); - - const [dbResult, tokenResult] = await Promise.all([dbFetch, tokenFetch]); - - spin.stop(); - - if (!url && dbResult) url = dbResult.data?.db?.url; - if (willGenerateToken && tokenResult) token = tokenResult.token; + const fetchDatabase = () => + apiClient.GET("/v2/databases/{db_id}", { + params: { path: { db_id: databaseId } }, + }); + + const mintToken = () => { + spin.text = "Generating token..."; + return generateToken(apiClient, databaseId, { + authorization: "full-access", + expiresAt: tokenExpiryFromNow(), + }); + }; + + try { + if (url && willGenerateToken) { + // Verify the override before creating a token, so a token is never created for a host we'd refuse. + const { data } = await fetchDatabase(); + const canonical = data?.db?.url; + + if (!canonical) { + throw new UserError(`Could not fetch database ${databaseId}.`); + } + + if (!sameHost(url, canonical)) { + throw new UserError( + `--url does not point at ${databaseId}.`, + `Pass --token for that URL, or drop --url to connect to ${canonical}.`, + ); + } + + token = (await mintToken())?.token; + } else { + const [dbResult, tokenResult] = await Promise.all([ + url ? Promise.resolve(null) : fetchDatabase(), + willGenerateToken ? mintToken() : Promise.resolve(null), + ]); + + if (!url) url = dbResult?.data?.db?.url; + if (willGenerateToken) token = tokenResult?.token; + } + } finally { + spin.stop(); + } if (!url || !token) { throw new UserError("Could not resolve database URL or generate token."); diff --git a/packages/cli/src/commands/db/migrations/apply.ts b/packages/cli/src/commands/db/migrations/apply.ts index 569fdd2a..1fd86b17 100644 --- a/packages/cli/src/commands/db/migrations/apply.ts +++ b/packages/cli/src/commands/db/migrations/apply.ts @@ -11,9 +11,9 @@ import { applyMigration, discoverMigrations, ensureMigrationsTable, - fetchApplied, migrationStatuses, pendingMigrations, + readApplied, resolveMigrationsDir, } from "./engine.ts"; @@ -134,8 +134,8 @@ export const dbMigrationsApplyCommand = defineCommand({ const { createClient } = await import("@libsql/client/web"); const client = createClient({ url, authToken: token }); - await ensureMigrationsTable(client); - const applied = await fetchApplied(client); + // Read without creating the table, so --dry-run and a declined confirm leave the database untouched. + const applied = await readApplied(client); const statuses = migrationStatuses(files, applied); const pending = pendingMigrations(files, applied); @@ -193,6 +193,8 @@ export const dbMigrationsApplyCommand = defineCommand({ return; } + await ensureMigrationsTable(client); + const done: string[] = []; for (const file of pending) { @@ -210,12 +212,11 @@ export const dbMigrationsApplyCommand = defineCommand({ } } catch (err: unknown) { spin.stop(); - const remaining = pending.length - done.length - 1; + // The failed file rolled back, so it is still pending along with everything unattempted. + const remaining = pending.length - done.length; throw new UserError( `${file.name} failed: ${errorMessage(err)}`, - done.length > 0 || remaining > 0 - ? `${done.length} applied, ${remaining} still pending. Fix ${file.name} and re-run.` - : undefined, + `${done.length} applied, ${remaining} still pending. Fix ${file.name} and re-run.`, ); } } diff --git a/packages/cli/src/commands/db/migrations/engine.test.ts b/packages/cli/src/commands/db/migrations/engine.test.ts index b2c9e24a..ad76a42e 100644 --- a/packages/cli/src/commands/db/migrations/engine.test.ts +++ b/packages/cli/src/commands/db/migrations/engine.test.ts @@ -20,6 +20,7 @@ import { migrationsTableExists, nextSequence, pendingMigrations, + readApplied, resolveMigrationsDir, slugify, } from "./engine.ts"; @@ -356,6 +357,27 @@ describe("migrationsTableExists", () => { }); }); +describe("readApplied", () => { + test("returns empty without creating the table", async () => { + const client = memoryClient(); + expect(await readApplied(client)).toEqual([]); + expect(await migrationsTableExists(client)).toBe(false); + }); + + test("turns a connection failure into a hinted UserError", async () => { + const broken = { + execute: async () => { + throw new Error("SERVER_ERROR: Server returned HTTP status 404"); + }, + migrate: async () => [], + } as unknown as MigrationClient; + + await expect(readApplied(broken)).rejects.toThrow( + /Could not read migration state: SERVER_ERROR/, + ); + }); +}); + describe("ensureMigrationsTable", () => { test("is idempotent and preserves rows", async () => { const client = memoryClient(); diff --git a/packages/cli/src/commands/db/migrations/engine.ts b/packages/cli/src/commands/db/migrations/engine.ts index 0c9e4b14..a1b32189 100644 --- a/packages/cli/src/commands/db/migrations/engine.ts +++ b/packages/cli/src/commands/db/migrations/engine.ts @@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { splitStatements } from "@bunny.net/database-shell"; import type { Client } from "@libsql/client"; -import { UserError } from "../../../core/errors.ts"; +import { errorMessage, UserError } from "../../../core/errors.ts"; import { DEFAULT_MIGRATIONS_DIR, FALLBACK_MIGRATIONS_DIRS, @@ -181,6 +181,29 @@ export async function fetchApplied( })); } +/** + * Read the applied migrations without creating the tracking table. + * + * Used by the read paths (`list`, and `apply` before it has confirmation) so a + * preview never writes. Connection and query failures become `UserError`, since + * a bad URL or token is a user problem, not a crash. + */ +export async function readApplied( + client: MigrationClient, + table = MIGRATIONS_TABLE, +): Promise { + try { + return (await migrationsTableExists(client, table)) + ? await fetchApplied(client, table) + : []; + } catch (err: unknown) { + throw new UserError( + `Could not read migration state: ${errorMessage(err)}`, + "Check that the database URL and token are correct.", + ); + } +} + /** * Join the files on disk with what the database has recorded. * diff --git a/packages/cli/src/commands/db/migrations/list.ts b/packages/cli/src/commands/db/migrations/list.ts index 15bbad48..81bae4e3 100644 --- a/packages/cli/src/commands/db/migrations/list.ts +++ b/packages/cli/src/commands/db/migrations/list.ts @@ -7,11 +7,9 @@ import { resolveCredentials } from "../credentials.ts"; import { ARG_DIR, MIGRATIONS_TABLE } from "./constants.ts"; import { warnOnDrift } from "./drift.ts"; import { - type AppliedMigration, discoverMigrations, - fetchApplied, migrationStatuses, - migrationsTableExists, + readApplied, resolveMigrationsDir, } from "./engine.ts"; @@ -105,9 +103,7 @@ export const dbMigrationsListCommand = defineCommand({ const client = createClient({ url, authToken: token }); // Don't create the tracking table from a read-only command. - const applied: AppliedMigration[] = (await migrationsTableExists(client)) - ? await fetchApplied(client) - : []; + const applied = await readApplied(client); const statuses = migrationStatuses(files, applied); diff --git a/packages/database-shell/src/parser.ts b/packages/database-shell/src/parser.ts index 426db058..66c70506 100644 --- a/packages/database-shell/src/parser.ts +++ b/packages/database-shell/src/parser.ts @@ -1,11 +1,26 @@ /** Statements whose body is a `BEGIN ... END` block, so inner semicolons don't terminate them. */ const BLOCK_BODY_START = /^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i; -/** True when `current` opens a `BEGIN ... END` block that hasn't been closed yet. */ +/** Quoted strings and identifiers, so keywords inside them don't affect nesting. */ +const QUOTED = /'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]/g; + +/** + * True when `current` opens a `BEGIN ... END` block that hasn't been closed yet. + * + * `BEGIN` opens the trigger body and `CASE` opens an expression; both are closed + * by `END`, so the body ends only once every opener has been matched. Counting + * rather than checking for a trailing `END` is what keeps a body statement like + * `SET x = CASE ... END;` from being mistaken for the end of the trigger. + */ function inBlockBody(current: string): boolean { const trimmed = current.trim(); if (!BLOCK_BODY_START.test(trimmed)) return false; - return !/\bEND$/i.test(trimmed); + + const bare = trimmed.replace(QUOTED, ""); + const openers = (bare.match(/\b(?:BEGIN|CASE)\b/gi) ?? []).length; + const closers = (bare.match(/\bEND\b/gi) ?? []).length; + + return closers < openers; } /** diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index d675a4da..0ecb4978 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -589,6 +589,27 @@ describe("splitStatements", () => { ]); }); + test("keeps a trigger body whose statement ends in CASE ... END intact", () => { + const sql = + "CREATE TRIGGER grade AFTER UPDATE ON scores BEGIN\n UPDATE scores SET band = CASE WHEN NEW.v > 90 THEN 'a' ELSE 'b' END;\n UPDATE scores SET seen = 1;\nEND;"; + expect(splitStatements(sql)).toEqual([sql.slice(0, -1)]); + }); + + test("handles nested CASE expressions in a trigger body", () => { + const sql = + "CREATE TRIGGER t AFTER INSERT ON x BEGIN\n UPDATE x SET a = CASE WHEN b THEN CASE WHEN c THEN 1 ELSE 2 END ELSE 3 END;\nEND;\nSELECT 1;"; + expect(splitStatements(sql)).toEqual([ + "CREATE TRIGGER t AFTER INSERT ON x BEGIN\n UPDATE x SET a = CASE WHEN b THEN CASE WHEN c THEN 1 ELSE 2 END ELSE 3 END;\nEND", + "SELECT 1", + ]); + }); + + test("ignores block keywords inside strings and quoted identifiers", () => { + const sql = + "CREATE TRIGGER t AFTER INSERT ON x BEGIN\n INSERT INTO log (\"end\") VALUES ('CASE END');\nEND;"; + expect(splitStatements(sql)).toEqual([sql.slice(0, -1)]); + }); + test("splits drizzle statement-breakpoint files", () => { const sql = "CREATE TABLE `users` (\n\t`id` integer PRIMARY KEY NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `users_id` ON `users` (`id`);"; diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index 9ed23b5d..e5a84f06 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -177,6 +177,11 @@ bunny db shell --url libsql://... --token ey... # explicit credentials 2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` 3. API lookup (fetches URL and generates a temporary token) +Shared by `db shell`, `db studio`, and `db migrations apply`. Two rules apply: + +- **Passing a database ID skips `.env`.** `bunny db shell db_01ABC` targets that database even when `.env` describes another one, so an explicit target is never silently redirected. +- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own host; a mismatch errors and asks for `--token`. + ### REPL dot-commands In interactive mode, the shell supports dot-commands like `.tables`, `.schema`, `.fk`, etc. From 68ed0f1c13049c40019fde3d76500c2dde78d853 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 13:49:00 +0100 Subject: [PATCH 03/10] fix(db): handle block comments and plaintext token targets Parser: block comments were not tokenized at all, so `/* END */` inside a trigger body counted as a structural closer, and more broadly a `;` or a quote inside any block comment split or corrupted the statement around it. Block comments are now skipped like `--` comments, which fixes both. Credentials: a hostname match let a plaintext URL receive a token. A token the user did not pass on the command line now requires an encrypted target, rejecting `http:`, `ws:`, and `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. This covers the token read from `.env` as well as a generated one, since neither was paired with the URL by the user. The scheme check runs before any lookup or prompt, so an unusable URL fails immediately rather than after picking a database. An explicit `--token` alongside a plaintext `--url` is still allowed: that pairing is deliberate, and it covers a local sqld over http. --- .changeset/db-migrations.md | 2 +- AGENTS.md | 6 ++- .../cli/src/commands/db/credentials.test.ts | 28 +++++++++- packages/cli/src/commands/db/credentials.ts | 53 ++++++++++++++++--- packages/database-shell/src/parser.ts | 12 ++++- packages/database-shell/src/shell.test.ts | 27 ++++++++++ skills/bunny-cli/references/database.md | 1 + 7 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md index a8d3648d..2b61dbc5 100644 --- a/.changeset/db-migrations.md +++ b/.changeset/db-migrations.md @@ -3,4 +3,4 @@ "@bunny.net/database-shell": patch --- -feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` keeps `CREATE TRIGGER` bodies intact; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a generated token to a `--url` on a different host +feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` keeps `CREATE TRIGGER` bodies intact and drops block comments; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a token to a `--url` on a different host or over an unencrypted connection diff --git a/AGENTS.md b/AGENTS.md index bc33de35..380ebca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1472,7 +1472,11 @@ interface ShellLogger { **CLI wrapper** (`packages/cli/src/commands/db/shell.ts`) provides: -- Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply`. Two safety rules live there: an explicit database ID skips `.env` entirely (it may describe a different database, and silently connecting there would target the wrong one), and a generated token is only sent to a URL whose host matches that database's canonical URL, so `--url` without `--token` is rejected on mismatch rather than handing a full-access token to an unverified host. +- Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply`. Its job is to never pair a credential with a target the user didn't pair it with: + +- An explicit database ID skips `.env` entirely. `.env` may describe a different database, and silently connecting there would target the wrong one. +- A generated token is only sent to a URL whose host matches that database's canonical URL, so `--url` without `--token` is rejected on mismatch. The host check runs before the token is created, so nothing is minted for an endpoint we'd refuse. +- A token bound for an explicit `--url` must travel encrypted unless the user passed it as `--token` on the same command line. `isEncrypted()` allows `libsql:`, `https:`, and `wss:`, and rejects `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. The scheme check runs first of all, before any lookup or prompt. An explicit `--token` with a plaintext `--url` is left alone: that pairing is deliberate, and it covers a local `sqld` over http. - `shellLogger()` adapter that wraps the CLI `logger` - `createClient()` call and delegation to `startShell()`/`executeQuery()`/`executeFile()` - Passes resolved `databaseId` and optional `--views-dir` to `startShell()` for saved views diff --git a/packages/cli/src/commands/db/credentials.test.ts b/packages/cli/src/commands/db/credentials.test.ts index c862be61..69cf39a0 100644 --- a/packages/cli/src/commands/db/credentials.test.ts +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -1,8 +1,34 @@ import { describe, expect, test } from "bun:test"; -import { sameHost } from "./credentials.ts"; +import { isEncrypted, sameHost } from "./credentials.ts"; const CANONICAL = "libsql://my-db-abc.lite.bunnydb.net/"; +describe("isEncrypted", () => { + test("accepts libsql, https, and wss", () => { + expect(isEncrypted("libsql://h.lite.bunnydb.net")).toBe(true); + expect(isEncrypted("https://h.lite.bunnydb.net")).toBe(true); + expect(isEncrypted("wss://h.lite.bunnydb.net")).toBe(true); + }); + + test("rejects plaintext schemes", () => { + expect(isEncrypted("http://h.lite.bunnydb.net")).toBe(false); + expect(isEncrypted("ws://h.lite.bunnydb.net")).toBe(false); + }); + + test("rejects libsql that opts out of TLS, which downgrades to http", () => { + expect(isEncrypted("libsql://h.lite.bunnydb.net:8080?tls=0")).toBe(false); + }); + + test("still accepts libsql with tls left on", () => { + expect(isEncrypted("libsql://h.lite.bunnydb.net:8080?tls=1")).toBe(true); + }); + + test("rejects unparseable input", () => { + expect(isEncrypted("h.lite.bunnydb.net")).toBe(false); + expect(isEncrypted("")).toBe(false); + }); +}); + describe("sameHost", () => { test("accepts the canonical URL with or without a trailing slash", () => { expect(sameHost("libsql://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index de9c93ff..f1efc96e 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -25,6 +25,25 @@ export interface ResolveCredentialsOptions { verbose?: boolean; } +/** Schemes that encrypt in transit. `libsql:` resolves to `https:`/`wss:` unless it opts out with `?tls=0`. */ +const ENCRYPTED_SCHEMES = new Set(["libsql:", "https:", "wss:"]); + +/** + * True when traffic to this URL is encrypted, so a token we create can be sent to it. + * + * The scheme alone isn't enough: `libsql://host:port?tls=0` downgrades to + * plaintext `http:`/`ws:` inside the libSQL client. + */ +export function isEncrypted(url: string): boolean { + try { + const parsed = new URL(url); + if (!ENCRYPTED_SCHEMES.has(parsed.protocol)) return false; + return parsed.searchParams.get("tls") !== "0"; + } catch { + return false; + } +} + /** Same host, ignoring scheme, port, and path, since `libsql://` and `https://` address the same endpoint. */ export function sameHost(a: string, b: string): boolean { try { @@ -47,9 +66,13 @@ export function sameHost(a: string, b: string): boolean { * An explicit database ID skips step 2 entirely: `.env` may describe a different * database, and silently connecting there would target the wrong database. * - * A generated token is only ever sent to a URL that belongs to the database it - * was created for, so overriding `--url` without `--token` is rejected rather - * than handing a full-access token to an unverified host. + * A generated token is only ever sent to an encrypted URL that belongs to the + * database it was created for, so overriding `--url` without `--token` is + * rejected rather than handing a full-access token to an unverified or + * plaintext endpoint. A token read from `.env` is likewise refused for a + * plaintext `--url`, since the user never paired the two. A token passed as + * `--token` alongside `--url` is left alone: that pairing is explicit, and it + * covers connecting to a local `sqld` over plain http. * * Shared by `db shell`, `db studio`, and `db migrations apply`. */ @@ -57,13 +80,23 @@ export async function resolveCredentials( opts: ResolveCredentialsOptions, ): Promise { const useEnv = !opts.databaseId; + const envToken = useEnv + ? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value + : undefined; + let url = opts.url ?? (useEnv ? readEnvValue(ENV_DATABASE_URL)?.value : undefined); - let token = - opts.token ?? - (useEnv ? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value : undefined); + let token = opts.token ?? envToken; if (url && token) { + // A stored token wasn't paired with this URL by the user, so don't leak it in the clear. + if (opts.url && !opts.token && envToken && !isEncrypted(opts.url)) { + throw new UserError( + "--url must be encrypted to receive the token from .env.", + "Use libsql:// or https://, or pass --token to send a credential of your choosing.", + ); + } + return { url, token, @@ -72,6 +105,14 @@ export async function resolveCredentials( }; } + // Refuse a plaintext target up front, before any lookup, prompt, or token creation. + if (opts.url && !token && !isEncrypted(opts.url)) { + throw new UserError( + "--url must be encrypted to receive a generated token.", + "Use libsql:// or https://, or pass --token to send your own credential.", + ); + } + const config = resolveConfig(opts.profile, opts.apiKey, opts.verbose); const apiClient = createDbClient(clientOptions(config, opts.verbose)); diff --git a/packages/database-shell/src/parser.ts b/packages/database-shell/src/parser.ts index 66c70506..2bd44586 100644 --- a/packages/database-shell/src/parser.ts +++ b/packages/database-shell/src/parser.ts @@ -25,7 +25,8 @@ function inBlockBody(current: string): boolean { /** * Split a SQL string into individual statements, handling single-quoted strings - * and `--` line comments. Trims whitespace and filters empty results. + * and both `--` line and block comments. Trims whitespace and filters empty + * results. Comments are dropped, so a `;` or a quote inside one is inert. * * `CREATE TRIGGER` bodies are kept intact: semicolons inside `BEGIN ... END` * don't split the statement. @@ -48,6 +49,15 @@ export function splitStatements(sql: string): string[] { continue; } + // Handle /* */ block comments (only outside strings) + if (!inString && ch === "/" && sql[i + 1] === "*") { + const close = sql.indexOf("*/", i + 2); + if (close === -1) break; + i = close + 1; + current += " "; + continue; + } + if (ch === "'") { if (inString) { // '' is an escaped quote inside a string, not end of string diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index 0ecb4978..906bc6b1 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -589,6 +589,33 @@ describe("splitStatements", () => { ]); }); + test("drops block comments and the semicolons inside them", () => { + expect(splitStatements("SELECT 1 /* a ; b */;")).toEqual(["SELECT 1"]); + expect(splitStatements("SELECT 1; /* between */ SELECT 2;")).toEqual([ + "SELECT 1", + "SELECT 2", + ]); + }); + + test("ignores quotes inside block comments", () => { + expect(splitStatements("SELECT 1 /* it's fine */; SELECT 2;")).toEqual([ + "SELECT 1", + "SELECT 2", + ]); + }); + + test("does not treat a block comment as a trigger block closer", () => { + const sql = + "CREATE TRIGGER t AFTER INSERT ON x BEGIN\n /* END of story */\n UPDATE x SET a = 1;\nEND;"; + expect(splitStatements(sql)).toHaveLength(1); + expect(splitStatements(sql)[0]).toContain("UPDATE x SET a = 1;"); + expect(splitStatements(sql)[0]?.endsWith("END")).toBe(true); + }); + + test("stops at an unterminated block comment without losing the statement", () => { + expect(splitStatements("SELECT 1; /* never closed")).toEqual(["SELECT 1"]); + }); + test("keeps a trigger body whose statement ends in CASE ... END intact", () => { const sql = "CREATE TRIGGER grade AFTER UPDATE ON scores BEGIN\n UPDATE scores SET band = CASE WHEN NEW.v > 90 THEN 'a' ELSE 'b' END;\n UPDATE scores SET seen = 1;\nEND;"; diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index e5a84f06..8cc42357 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -181,6 +181,7 @@ Shared by `db shell`, `db studio`, and `db migrations apply`. Two rules apply: - **Passing a database ID skips `.env`.** `bunny db shell db_01ABC` targets that database even when `.env` describes another one, so an explicit target is never silently redirected. - **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own host; a mismatch errors and asks for `--token`. +- **`--url` must be encrypted to receive a token you didn't pass yourself.** `libsql://`, `https://`, and `wss://` are accepted; `http://`, `ws://`, and `libsql://host:port?tls=0` are refused, whether the token would be generated or read from `.env`. Passing `--token` alongside a plaintext `--url` is allowed, for cases like a local `sqld`. ### REPL dot-commands From 3f839e9dd0c9f9c830b9e4a455253779211fc53f Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 19:49:45 +0100 Subject: [PATCH 04/10] fix(db): bind the .env token to the .env URL An encrypted `--url` on a foreign host still received the token from `.env`, because the plaintext guard added in the previous commit was the only check on that path. Last round I assumed host ownership couldn't be verified there without an API call, which was wrong: the `.env` URL is the pairing the user established, so comparing against it is a local check. The `.env` token is now reused only for a `--url` on the same host as the `.env` URL. Anything else falls through to the API path, where a fresh token is created and checked against the database's canonical URL, so the stored credential is never the one that travels. Comparing against `.env` rather than the API keeps the offline case working: both values in `.env` with `--url` naming the same host still needs no network call. Folding the encryption check into the same predicate removes the separate `.env`-specific error path; a plaintext override now falls through to the existing "must be encrypted" refusal. --- AGENTS.md | 7 ++- .../cli/src/commands/db/credentials.test.ts | 45 ++++++++++++++++- packages/cli/src/commands/db/credentials.ts | 48 ++++++++++++------- skills/bunny-cli/references/database.md | 6 ++- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 380ebca9..db39aab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1476,7 +1476,12 @@ interface ShellLogger { - An explicit database ID skips `.env` entirely. `.env` may describe a different database, and silently connecting there would target the wrong one. - A generated token is only sent to a URL whose host matches that database's canonical URL, so `--url` without `--token` is rejected on mismatch. The host check runs before the token is created, so nothing is minted for an endpoint we'd refuse. -- A token bound for an explicit `--url` must travel encrypted unless the user passed it as `--token` on the same command line. `isEncrypted()` allows `libsql:`, `https:`, and `wss:`, and rejects `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. The scheme check runs first of all, before any lookup or prompt. An explicit `--token` with a plaintext `--url` is left alone: that pairing is deliberate, and it covers a local `sqld` over http. +- The `.env` token is only reused for an explicit `--url` on the same host as the `.env` URL (`envTokenAllowedFor()`). That pairing is the user's own and holds for nothing else, so an override addressing anywhere else falls through to the API path, where a fresh token is created and checked against the canonical URL. The comparison is against `.env` rather than the API so the offline case (both values in `.env`, `--url` naming the same host) still needs no network call. +- A token bound for an explicit `--url` must travel encrypted. `isEncrypted()` allows `libsql:`, `https:`, and `wss:`, and rejects `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. The scheme check runs first of all, before any lookup or prompt, so an unusable URL fails immediately instead of after a database prompt. +- An explicit `--token` is exempt from all of the above: pairing it with `--url` is deliberate, and it covers a local `sqld` over plain http. + +The invariant behind all of it: a credential the user didn't pass on this command line is never sent to a target they did. + - `shellLogger()` adapter that wraps the CLI `logger` - `createClient()` call and delegation to `startShell()`/`executeQuery()`/`executeFile()` - Passes resolved `databaseId` and optional `--views-dir` to `startShell()` for saved views diff --git a/packages/cli/src/commands/db/credentials.test.ts b/packages/cli/src/commands/db/credentials.test.ts index 69cf39a0..079ee30e 100644 --- a/packages/cli/src/commands/db/credentials.test.ts +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -1,8 +1,51 @@ import { describe, expect, test } from "bun:test"; -import { isEncrypted, sameHost } from "./credentials.ts"; +import { envTokenAllowedFor, isEncrypted, sameHost } from "./credentials.ts"; const CANONICAL = "libsql://my-db-abc.lite.bunnydb.net/"; +describe("envTokenAllowedFor", () => { + test("allows the .env token when no --url overrides it", () => { + expect(envTokenAllowedFor(undefined, CANONICAL)).toBe(true); + expect(envTokenAllowedFor(undefined, undefined)).toBe(true); + }); + + test("allows a --url naming the same host as the .env URL", () => { + expect( + envTokenAllowedFor("libsql://my-db-abc.lite.bunnydb.net", CANONICAL), + ).toBe(true); + expect( + envTokenAllowedFor("https://my-db-abc.lite.bunnydb.net", CANONICAL), + ).toBe(true); + }); + + test("refuses an encrypted --url on a different host", () => { + expect(envTokenAllowedFor("https://evil.example.com", CANONICAL)).toBe( + false, + ); + expect( + envTokenAllowedFor("libsql://other-db.lite.bunnydb.net", CANONICAL), + ).toBe(false); + }); + + test("refuses a plaintext --url even on the matching host", () => { + expect( + envTokenAllowedFor("http://my-db-abc.lite.bunnydb.net", CANONICAL), + ).toBe(false); + expect( + envTokenAllowedFor( + "libsql://my-db-abc.lite.bunnydb.net:8080?tls=0", + CANONICAL, + ), + ).toBe(false); + }); + + test("refuses when .env has a token but no URL to pair it with", () => { + expect( + envTokenAllowedFor("https://my-db-abc.lite.bunnydb.net", undefined), + ).toBe(false); + }); +}); + describe("isEncrypted", () => { test("accepts libsql, https, and wss", () => { expect(isEncrypted("libsql://h.lite.bunnydb.net")).toBe(true); diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index f1efc96e..8078d5f9 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -55,6 +55,26 @@ export function sameHost(a: string, b: string): boolean { } } +/** + * True when the token stored in `.env` may be sent to an explicit `--url`. + * + * The `.env` token belongs to the `.env` URL: that pairing is the user's own, so + * it holds for the same host and nothing else. An override addressing anywhere + * else falls through to the API path, where a fresh token is created and checked + * against the database's canonical URL instead of reusing the stored one. + * + * Checked against `.env` rather than the API so the offline case (both values in + * `.env`, `--url` naming the same host) still needs no network call. + */ +export function envTokenAllowedFor( + explicitUrl: string | undefined, + envUrl: string | undefined, +): boolean { + if (!explicitUrl) return true; + if (!envUrl) return false; + return sameHost(explicitUrl, envUrl) && isEncrypted(explicitUrl); +} + /** * Resolve the database URL and auth token needed to connect over libSQL. * @@ -66,13 +86,12 @@ export function sameHost(a: string, b: string): boolean { * An explicit database ID skips step 2 entirely: `.env` may describe a different * database, and silently connecting there would target the wrong database. * - * A generated token is only ever sent to an encrypted URL that belongs to the - * database it was created for, so overriding `--url` without `--token` is - * rejected rather than handing a full-access token to an unverified or - * plaintext endpoint. A token read from `.env` is likewise refused for a - * plaintext `--url`, since the user never paired the two. A token passed as - * `--token` alongside `--url` is left alone: that pairing is explicit, and it - * covers connecting to a local `sqld` over plain http. + * The rule for tokens is that a credential the user didn't pass on this command + * line is never sent to a target they did. So a generated token only goes to an + * encrypted URL belonging to the database it was created for, and the `.env` + * token only goes to an encrypted `--url` on the same host as the `.env` URL. + * A token passed as `--token` is left alone: pairing it with `--url` is explicit, + * and it covers connecting to a local `sqld` over plain http. * * Shared by `db shell`, `db studio`, and `db migrations apply`. */ @@ -80,23 +99,16 @@ export async function resolveCredentials( opts: ResolveCredentialsOptions, ): Promise { const useEnv = !opts.databaseId; + const envUrl = useEnv ? readEnvValue(ENV_DATABASE_URL)?.value : undefined; const envToken = useEnv ? readEnvValue(ENV_DATABASE_AUTH_TOKEN)?.value : undefined; - let url = - opts.url ?? (useEnv ? readEnvValue(ENV_DATABASE_URL)?.value : undefined); - let token = opts.token ?? envToken; + let url = opts.url ?? envUrl; + let token = + opts.token ?? (envTokenAllowedFor(opts.url, envUrl) ? envToken : undefined); if (url && token) { - // A stored token wasn't paired with this URL by the user, so don't leak it in the clear. - if (opts.url && !opts.token && envToken && !isEncrypted(opts.url)) { - throw new UserError( - "--url must be encrypted to receive the token from .env.", - "Use libsql:// or https://, or pass --token to send a credential of your choosing.", - ); - } - return { url, token, diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index 8cc42357..25ed8d14 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -180,8 +180,10 @@ bunny db shell --url libsql://... --token ey... # explicit credentials Shared by `db shell`, `db studio`, and `db migrations apply`. Two rules apply: - **Passing a database ID skips `.env`.** `bunny db shell db_01ABC` targets that database even when `.env` describes another one, so an explicit target is never silently redirected. -- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own host; a mismatch errors and asks for `--token`. -- **`--url` must be encrypted to receive a token you didn't pass yourself.** `libsql://`, `https://`, and `wss://` are accepted; `http://`, `ws://`, and `libsql://host:port?tls=0` are refused, whether the token would be generated or read from `.env`. Passing `--token` alongside a plaintext `--url` is allowed, for cases like a local `sqld`. +- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own host; a mismatch errors and asks for `--token`. The token from `.env` is likewise only reused for a `--url` on the same host as `BUNNY_DATABASE_URL`. +- **`--url` must be encrypted to receive a token you didn't pass yourself.** `libsql://`, `https://`, and `wss://` are accepted; `http://`, `ws://`, and `libsql://host:port?tls=0` are refused. + +In short, a credential you didn't type on the command line never goes to a URL you did. Passing `--token` alongside a plaintext or foreign `--url` is always allowed, for cases like a local `sqld`. ### REPL dot-commands From 50d741ef30d67580e571f6d8714750dba5f629ce Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 16:44:43 +0100 Subject: [PATCH 05/10] interactive create --- .gitignore | 3 +++ AGENTS.md | 4 ++-- .../cli/src/commands/db/migrations/create.ts | 23 +++++++++++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index dde602cc..3aff7416 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .bunny bunny bsql + +# Throwaway local testing +.test diff --git a/AGENTS.md b/AGENTS.md index db39aab5..b436717e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1062,8 +1062,8 @@ bunny │ ├── migrations Create and apply SQL migrations (files are the source of truth) │ │ ├── apply [database-id] [--dir] [--url] [--token] [--dry-run] [--force] │ │ │ Apply pending migrations in filename order (each file + its tracking row is one atomic batch) -│ │ ├── create (alias: new) [--dir] -│ │ │ Write an empty migrations/NNNN_.sql +│ │ ├── create [name] (alias: new) [--dir] +│ │ │ Write an empty migrations/NNNN_.sql (prompts for name when omitted) │ │ └── list [database-id] (aliases: ls, status) [--dir] [--url] [--token] │ │ Show applied / pending / modified / missing migrations │ ├── quickstart [database-id] [--lang] [--url] [--token] diff --git a/packages/cli/src/commands/db/migrations/create.ts b/packages/cli/src/commands/db/migrations/create.ts index 047b6150..14d25e76 100644 --- a/packages/cli/src/commands/db/migrations/create.ts +++ b/packages/cli/src/commands/db/migrations/create.ts @@ -1,8 +1,10 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; +import prompts from "prompts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; +import { isInteractive } from "../../../core/ui.ts"; import { ARG_DIR } from "./constants.ts"; import { discoverMigrations, @@ -11,12 +13,12 @@ import { slugify, } from "./engine.ts"; -const COMMAND = "create "; +const COMMAND = "create [name]"; const ALIASES = ["new"] as const; const DESCRIPTION = "Create an empty migration file."; interface CreateArgs { - name: string; + name?: string; [ARG_DIR]?: string; } @@ -41,6 +43,7 @@ export const dbMigrationsCreateCommand = defineCommand({ "$0 db migrations create add_users_table", "Create migrations/0001_add_users_table.sql", ], + ["$0 db migrations create", "Prompt for a name"], [ "$0 db migrations create add_index --dir db/migrations", "Use a custom directory", @@ -52,14 +55,26 @@ export const dbMigrationsCreateCommand = defineCommand({ .positional("name", { type: "string", describe: "Migration name, used as the filename suffix", - demandOption: true, }) .option(ARG_DIR, { type: "string", describe: "Migrations directory (default: migrations)", }), - handler: async ({ name, [ARG_DIR]: dirArg, output }) => { + handler: async ({ name: nameArg, [ARG_DIR]: dirArg, output }) => { + let name = nameArg; + if (!name && isInteractive(output)) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Migration name:", + validate: (v: string) => + /[a-z0-9]/i.test(v) || "Must contain at least one letter or number", + }); + name = value; + } + if (!name) throw new UserError("Migration name is required."); + const { dir } = resolveMigrationsDir(dirArg); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); From a81be6e81353bb0eecd79de5c7933669baa8f7f8 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 13 Aug 2026 14:15:47 +0100 Subject: [PATCH 06/10] Harden database migrations with glob patterns and drift checks --- .changeset/db-migrations.md | 2 +- AGENTS.md | 42 ++--- README.md | 5 +- .../cli/src/commands/db/credentials.test.ts | 30 +++- packages/cli/src/commands/db/credentials.ts | 25 +++ .../cli/src/commands/db/migrations/apply.ts | 94 +++++++++--- .../src/commands/db/migrations/constants.ts | 6 + .../cli/src/commands/db/migrations/create.ts | 4 +- .../cli/src/commands/db/migrations/drift.ts | 44 +++++- .../src/commands/db/migrations/engine.test.ts | 143 ++++++++++++++++++ .../cli/src/commands/db/migrations/engine.ts | 137 ++++++++++++++--- .../cli/src/commands/db/migrations/list.ts | 49 +++++- packages/database-shell/src/parser.ts | 80 +++++++--- packages/database-shell/src/shell.test.ts | 35 ++++- skills/bunny-cli/SKILL.md | 1 + skills/bunny-cli/references/database.md | 43 ++++-- 16 files changed, 621 insertions(+), 119 deletions(-) diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md index 2b61dbc5..e9aba964 100644 --- a/.changeset/db-migrations.md +++ b/.changeset/db-migrations.md @@ -3,4 +3,4 @@ "@bunny.net/database-shell": patch --- -feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `splitStatements` keeps `CREATE TRIGGER` bodies intact and drops block comments; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a token to a `--url` on a different host or over an unencrypted connection +feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `--pattern` supports nested ORM layouts while checksum drift and out-of-order files block unsafe applies unless `--allow-drift` is explicit; migration commands show the credential-free database target; `splitStatements` keeps `CREATE TRIGGER` bodies intact, supports every SQLite quote form, drops comments, and rejects truncated SQL; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a token to a `--url` on a different host or over an unencrypted connection diff --git a/AGENTS.md b/AGENTS.md index b436717e..6b46b171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,12 +292,12 @@ bunny-cli/ │ │ │ ├── usage.ts # Show database usage statistics │ │ │ ├── migrations/ │ │ │ │ ├── index.ts # defineNamespace("migrations", ...) — registers migration commands -│ │ │ │ ├── constants.ts # Default dir, drizzle fallback dir, tracking table name -│ │ │ │ ├── engine.ts # Shared: discover files, checksums, applied/pending state, apply one migration -│ │ │ │ ├── drift.ts # Shared: warn when applied migrations were edited or deleted -│ │ │ │ ├── apply.ts # Apply pending migrations in filename order -│ │ │ │ ├── create.ts # Write an empty numbered migration file -│ │ │ │ └── list.ts # Show applied/pending/modified/missing state +│ │ │ │ ├── constants.ts # Default dir/pattern, drizzle fallback dir, tracking table name +│ │ │ │ ├── engine.ts # Shared: glob discovery, checksums, applied/pending state, preflight parsing, apply one migration +│ │ │ │ ├── drift.ts # Shared: report and block modified/missing/out-of-order histories unless explicitly allowed +│ │ │ │ ├── apply.ts # Apply pending migrations in relative-path order; shows credential-free target +│ │ │ │ ├── create.ts # Write an empty numbered top-level migration file (never auto-detects ORM dirs) +│ │ │ │ └── list.ts # Show applied/pending/modified/missing/out-of-order state │ │ │ ├── regions/ │ │ │ │ ├── index.ts # defineNamespace("regions", ...) — registers region commands │ │ │ │ ├── add.ts # Add primary/replica regions (interactive multiselect or flags) @@ -1060,12 +1060,12 @@ bunny │ ├── list (alias: ls) [--group-id] │ │ List all databases │ ├── migrations Create and apply SQL migrations (files are the source of truth) -│ │ ├── apply [database-id] [--dir] [--url] [--token] [--dry-run] [--force] +│ │ ├── apply [database-id] [--dir] [--pattern] [--url] [--token] [--dry-run] [--force] [--allow-drift] │ │ │ Apply pending migrations in filename order (each file + its tracking row is one atomic batch) │ │ ├── create [name] (alias: new) [--dir] │ │ │ Write an empty migrations/NNNN_.sql (prompts for name when omitted) -│ │ └── list [database-id] (aliases: ls, status) [--dir] [--url] [--token] -│ │ Show applied / pending / modified / missing migrations +│ │ └── list [database-id] (aliases: ls, status) [--dir] [--pattern] [--url] [--token] +│ │ Show applied / pending / modified / missing / out-of-order migrations │ ├── quickstart [database-id] [--lang] [--url] [--token] │ │ Generate quickstart guide for a database │ ├── regions @@ -1456,7 +1456,7 @@ The shell is split across two packages: - **Formatting** (`format.ts`) — `printResultSet()` with 5 output modes: `default`, `table`, `json`, `csv`, `markdown`. Sensitive column masking (full mask for passwords/secrets, email mask for email columns). - **Views** (`views.ts`) — Saved queries scoped per database. Stored at `~/.config/bunny/views//` (respects `XDG_CONFIG_HOME`). Callers can override via `ShellOptions.viewsDir`. - **History** (`history.ts`) — Stored at `~/.config/bunny/shell_history` (respects `XDG_CONFIG_HOME`). Max 1000 entries. -- **SQL parsing** (`parser.ts`) — `splitStatements()` for `.sql` file execution. Splits on `;` outside string literals, strips `--` comments (so drizzle's `--> statement-breakpoint` markers are ignored), and keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact. +- **SQL parsing** (`parser.ts`) — `splitStatements()` for `.sql` file execution. Splits on `;` outside single-quoted strings and SQLite's double-quote/backtick/bracket identifier forms, strips line and block comments (so drizzle's `--> statement-breakpoint` markers are ignored), keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact, and rejects unterminated quotes/comments rather than returning truncated SQL. **Dependency injection** — The shell engine accepts a `ShellLogger` interface instead of importing the CLI logger directly: @@ -1515,38 +1515,42 @@ Schema changes live in plain `.sql` files that the developer writes (or generate ### Convention - Files live in `migrations/` by default, one statement group per file, named `NNNN_.sql`. -- The **filename is the migration's identity**, and its numeric prefix is the order. Nothing else (no journal, no manifest) tracks migrations locally. -- Files are applied in lexicographic filename order, which is why prefixes are zero-padded to four digits. +- The **relative path is the migration's identity**, and its numeric prefix is the order. Flat files use the filename; nested layouts opt in with `--pattern`. Nothing else (no journal, no manifest) tracks migrations locally. +- Files are applied in lexicographic relative-path order, which is why prefixes are zero-padded to four digits. - Applied migrations are recorded in `__bunny_migrations` (`id`, `name`, `checksum`, `applied_at`). The `__` prefix means `DEFAULT_EXCLUDE_PATTERNS` in `packages/database-adapter-libsql/src/introspect.ts` already hides it from `db studio` and the REST layer. ### Engine (`packages/cli/src/commands/db/migrations/engine.ts`) All file and state logic is here so the commands stay thin and the logic is testable against an in-memory libSQL database (`engine.test.ts`, no network): -- `resolveMigrationsDir(dirArg?)` — `--dir` wins; otherwise `migrations/`, falling back to `drizzle/` when `migrations/` doesn't exist (`detected: true` so the caller can say which directory it used). -- `discoverMigrations(dir)` — every `.sql` file, sorted by name. Skips dotfiles and subdirectories, so `drizzle/meta/` is ignored. +- `resolveMigrationsDir(dirArg?)` — `--dir` wins; otherwise `migrations/`, falling back to `drizzle/` when `migrations/` doesn't exist (`detected: true` so the caller can say which directory it used). `resolveCreateMigrationsDir()` deliberately skips fallback detection so `create` never writes an unjournaled file into an ORM directory. +- `discoverMigrations(dir, pattern)` — every `.sql` file matched by a positive `Bun.Glob` relative to `dir`, sorted by portable slash-separated relative path. The default `*.sql` stays top-level; `*/migration.sql` and `**/*.sql` opt into nested layouts. Absolute/traversing/negated patterns are rejected. - `checksum(sql)` — sha256 of the body with CRLF normalized and edges trimmed, so reformatting line endings isn't reported as a change. -- `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)` — join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`. Both are warnings (`drift.ts`), never fatal: pending migrations still apply cleanly, and the remedy is the developer's call. -- `applyMigration(client, file)` — splits the file with `splitStatements()` and runs the statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. +- `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)` — join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`; an unseen file that sorts before the newest applied path is `out_of_order`. +- `migrationStatements(file)` — parses one file and converts lexical failures into a hinted `UserError`. `apply` calls it for every pending migration before the first database write, so a malformed later file cannot cause a predictably partial run. +- `applyMigration(client, file, options)` — runs the prepared statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. - `readApplied(client)` — the read path for `list` and for `apply` before confirmation. Checks `sqlite_master` rather than creating the tracking table, so a preview never writes, and converts connection or query failures into a hinted `UserError` instead of an unexpected-error exit. `client.migrate()` is used rather than `client.batch()` because it defers foreign key enforcement for the batch, which table rebuilds and `ALTER TABLE` need. `db shell .sql` still uses `batch()` and is not migration-aware. ### ORM-generated migrations -`drizzle-kit generate` (sqlite/turso dialect) writes flat `0000_.sql` files, matching this convention, so no glob or pattern config is needed. Generate with the ORM, apply with the CLI: +Flat `drizzle-kit generate` output writes `0000_.sql` files, matching this convention with no pattern override. Nested ORM layouts use a glob relative to `--dir`: ```bash drizzle-kit generate # writes drizzle/0000_curly_bat.sql bunny db migrations apply # finds drizzle/ automatically bunny db migrations apply --dir drizzle # or be explicit +bunny db migrations apply --dir migrations --pattern "*/migration.sql" ``` -`db migrations create` only writes top-level files; use the ORM's own generate command when an ORM owns the schema. +`db migrations create` only writes top-level files and always defaults to `migrations/`; use the ORM's own generate command when an ORM owns the schema. One runner owns a migration history: Bunny records relative paths in `__bunny_migrations` and does not read or update another tool's journal, so users should run Drizzle/Prisma/dbmate directly rather than alternating runners over the same files. ### Applying -`apply` runs pending migrations sequentially and stops at the first failure, reporting how many applied and how many are still pending (the failed file counts as pending, since its tracking row rolled back with it). It confirms before writing when a TTY is attached, and skips the prompt under `--force` or any non-interactive run (`--output json`, no TTY) so CI and agents aren't blocked. `--dry-run` lists what would run without writing. +`apply` runs pending migrations sequentially and stops at the first database failure, reporting how many applied and how many are still pending (the failed file counts as pending, since its tracking row rolled back with it). It refuses to extend modified, missing, or out-of-order histories unless `--allow-drift` is explicit. It confirms before writing when a TTY is attached, and skips the prompt under `--force` or any non-interactive run (`--output json`, no TTY) so CI and agents aren't blocked. `--dry-run` lists what would run without writing. + +Both `list` and `apply` display a credential-free target (`database-id (host)` when the ID is known, otherwise the host). JSON output includes `{ database_id, host }`, the discovery pattern, and history issues; it never includes the token, URL path, query, or user info. Nothing is written before confirmation, including the tracking table: `ensureMigrationsTable()` runs only after the confirm and after the `--dry-run` exit, so a preview against read-only credentials lists pending files instead of failing on a schema write. diff --git a/README.md b/README.md index 53a2b1aa..9467322c 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ bun ny bun ny login bun ny db list bun ny db migrations create add_users # write migrations/0001_add_users.sql (numeric prefix = apply order) -bun ny db migrations list # show applied / pending migrations -bun ny db migrations apply # apply pending migrations in order (--dry-run to preview, --dir drizzle for drizzle-kit output) +bun ny db migrations list # show applied / pending / changed migrations +bun ny db migrations apply # apply pending migrations in order (--dry-run to preview, --dir drizzle for flat drizzle-kit output) +bun ny db migrations apply --pattern "*/migration.sql" # nested ORM layout; paths are tracked relative to migrations/ bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy bun ny apps deploy # first run? Imports docker-compose.yml if present; otherwise auto-detects Dockerfile(s) (including monorepo subdirs) so you can pick one or many, or falls back to a pre-built image. diff --git a/packages/cli/src/commands/db/credentials.test.ts b/packages/cli/src/commands/db/credentials.test.ts index 079ee30e..f018e8c2 100644 --- a/packages/cli/src/commands/db/credentials.test.ts +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -1,8 +1,36 @@ import { describe, expect, test } from "bun:test"; -import { envTokenAllowedFor, isEncrypted, sameHost } from "./credentials.ts"; +import { + databaseTarget, + envTokenAllowedFor, + isEncrypted, + sameHost, +} from "./credentials.ts"; const CANONICAL = "libsql://my-db-abc.lite.bunnydb.net/"; +describe("databaseTarget", () => { + test("shows the database ID and host without URL credentials or paths", () => { + expect( + databaseTarget( + "libsql://user:secret@my-db-abc.lite.bunnydb.net/private?token=nope", + "db_123", + ), + ).toEqual({ + databaseId: "db_123", + host: "my-db-abc.lite.bunnydb.net", + label: "db_123 (my-db-abc.lite.bunnydb.net)", + }); + }); + + test("falls back to the host when no database ID is known", () => { + expect(databaseTarget(CANONICAL)).toEqual({ + databaseId: null, + host: "my-db-abc.lite.bunnydb.net", + label: "my-db-abc.lite.bunnydb.net", + }); + }); +}); + describe("envTokenAllowedFor", () => { test("allows the .env token when no --url overrides it", () => { expect(envTokenAllowedFor(undefined, CANONICAL)).toBe(true); diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index 8078d5f9..9fe4834d 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -16,6 +16,12 @@ export interface ResolvedCredentials { tokenGenerated: boolean; } +export interface DatabaseTarget { + databaseId: string | null; + host: string; + label: string; +} + export interface ResolveCredentialsOptions { url?: string; token?: string; @@ -28,6 +34,25 @@ export interface ResolveCredentialsOptions { /** Schemes that encrypt in transit. `libsql:` resolves to `https:`/`wss:` unless it opts out with `?tls=0`. */ const ENCRYPTED_SCHEMES = new Set(["libsql:", "https:", "wss:"]); +/** A credential-free database identity suitable for prompts and structured output. */ +export function databaseTarget( + url: string, + databaseId?: string, +): DatabaseTarget { + let host = "unknown host"; + try { + host = new URL(url).host || host; + } catch { + // Credential resolution or the client will provide the actionable URL error. + } + + return { + databaseId: databaseId ?? null, + host, + label: databaseId ? `${databaseId} (${host})` : host, + }; +} + /** * True when traffic to this URL is encrypted, so a token we create can be sent to it. * diff --git a/packages/cli/src/commands/db/migrations/apply.ts b/packages/cli/src/commands/db/migrations/apply.ts index 1fd86b17..dd270eb3 100644 --- a/packages/cli/src/commands/db/migrations/apply.ts +++ b/packages/cli/src/commands/db/migrations/apply.ts @@ -4,13 +4,23 @@ import { errorMessage, UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; import { ARG_DATABASE_ID, TOKEN_TTL_MINUTES } from "../constants.ts"; -import { resolveCredentials } from "../credentials.ts"; -import { ARG_DIR, MIGRATIONS_TABLE } from "./constants.ts"; -import { warnOnDrift } from "./drift.ts"; +import { databaseTarget, resolveCredentials } from "../credentials.ts"; +import { + ARG_DIR, + ARG_PATTERN, + DEFAULT_MIGRATIONS_PATTERN, + MIGRATIONS_TABLE, +} from "./constants.ts"; +import { + assertMigrationHistorySafe, + migrationHistoryIssues, + warnOnDrift, +} from "./drift.ts"; import { applyMigration, discoverMigrations, ensureMigrationsTable, + migrationStatements, migrationStatuses, pendingMigrations, readApplied, @@ -25,14 +35,17 @@ const ARG_TOKEN = "token"; const ARG_DRY_RUN = "dry-run"; const ARG_FORCE = "force"; const ARG_FORCE_ALIAS = "f"; +const ARG_ALLOW_DRIFT = "allow-drift"; interface ApplyArgs { [ARG_DATABASE_ID]?: string; [ARG_DIR]?: string; + [ARG_PATTERN]?: string; [ARG_URL]?: string; [ARG_TOKEN]?: string; [ARG_DRY_RUN]?: boolean; [ARG_FORCE]?: boolean; + [ARG_ALLOW_DRIFT]?: boolean; } /** @@ -69,6 +82,11 @@ export const dbMigrationsApplyCommand = defineCommand({ type: "string", describe: "Migrations directory (default: migrations)", }) + .option(ARG_PATTERN, { + type: "string", + default: DEFAULT_MIGRATIONS_PATTERN, + describe: "Migration glob relative to --dir", + }) .option(ARG_URL, { type: "string", describe: "Database URL (skips API lookup)", @@ -87,15 +105,22 @@ export const dbMigrationsApplyCommand = defineCommand({ type: "boolean", default: false, describe: "Skip confirmation prompts", + }) + .option(ARG_ALLOW_DRIFT, { + type: "boolean", + default: false, + describe: "Apply despite modified, missing, or out-of-order history", }), handler: async ({ [ARG_DATABASE_ID]: databaseIdArg, [ARG_DIR]: dirArg, + [ARG_PATTERN]: pattern = DEFAULT_MIGRATIONS_PATTERN, [ARG_URL]: urlArg, [ARG_TOKEN]: tokenArg, [ARG_DRY_RUN]: dryRun, [ARG_FORCE]: force, + [ARG_ALLOW_DRIFT]: allowDrift, profile, output, verbose, @@ -104,26 +129,34 @@ export const dbMigrationsApplyCommand = defineCommand({ const json = output === "json"; const { dir, detected } = resolveMigrationsDir(dirArg); - const files = discoverMigrations(dir); + const files = discoverMigrations(dir, pattern); const displayDir = relative(process.cwd(), dir) || "."; if (files.length === 0) { + const nested = + pattern === DEFAULT_MIGRATIONS_PATTERN + ? discoverMigrations(dir, "**/*.sql") + : []; throw new UserError( - `No migrations found in ${displayDir}.`, - "Run `bunny db migrations create ` to add one.", + `No migrations matched ${pattern} in ${displayDir}.`, + nested.length > 0 + ? 'Nested SQL files were found. Pass a matching glob such as `--pattern "*/migration.sql"`.' + : "Run `bunny db migrations create ` to add one.", ); } if (detected && !json) logger.dim(`Using ${displayDir}`); - const { url, token, tokenGenerated } = await resolveCredentials({ - url: urlArg, - token: tokenArg, - databaseId: databaseIdArg, - profile, - apiKey, - verbose, - }); + const { url, token, tokenGenerated, databaseId } = await resolveCredentials( + { + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + }, + ); if (tokenGenerated && !json) { logger.dim( @@ -133,11 +166,15 @@ export const dbMigrationsApplyCommand = defineCommand({ const { createClient } = await import("@libsql/client/web"); const client = createClient({ url, authToken: token }); + const target = databaseTarget(url, databaseId); + + if (!json) logger.dim(`Database: ${target.label}`); // Read without creating the table, so --dry-run and a declined confirm leave the database untouched. const applied = await readApplied(client); const statuses = migrationStatuses(files, applied); const pending = pendingMigrations(files, applied); + const issues = migrationHistoryIssues(statuses); /** `pending` is what was outstanding at the start; `done` is what actually ran. */ const report = (done: string[]) => @@ -145,9 +182,18 @@ export const dbMigrationsApplyCommand = defineCommand({ JSON.stringify( { dir: displayDir, + pattern, table: MIGRATIONS_TABLE, - pending: pending.map((f) => f.name), + target: { + database_id: target.databaseId, + host: target.host, + }, + planned: pending.map((f) => f.name), applied: done, + remaining: pending + .filter((file) => !done.includes(file.name)) + .map((file) => file.name), + issues: issues.map(({ name, state }) => ({ name, state })), dry_run: Boolean(dryRun), }, null, @@ -160,7 +206,9 @@ export const dbMigrationsApplyCommand = defineCommand({ report([]); return; } - logger.success("Already up to date."); + logger.success( + issues.length === 0 ? "Already up to date." : "No pending migrations.", + ); warnOnDrift(statuses); return; } @@ -174,6 +222,14 @@ export const dbMigrationsApplyCommand = defineCommand({ warnOnDrift(statuses); } + assertMigrationHistorySafe(statuses, Boolean(allowDrift)); + + // Parse every pending file before the first database write, so a malformed + // later file cannot leave the run predictably half-complete. + const prepared = new Map( + pending.map((file) => [file.name, migrationStatements(file)]), + ); + if (dryRun) { if (json) { report([]); @@ -184,7 +240,7 @@ export const dbMigrationsApplyCommand = defineCommand({ } // Prompt only when a human is watching, so CI and agent runs aren't blocked. - const confirmed = await confirm("Apply now?", { + const confirmed = await confirm(`Apply to ${target.label}?`, { force: force || !isInteractive(output), initial: true, }); @@ -202,7 +258,9 @@ export const dbMigrationsApplyCommand = defineCommand({ if (!json) spin.start(); try { - const { statements } = await applyMigration(client, file); + const { statements } = await applyMigration(client, file, { + statements: prepared.get(file.name), + }); spin.stop(); done.push(file.name); if (!json) { diff --git a/packages/cli/src/commands/db/migrations/constants.ts b/packages/cli/src/commands/db/migrations/constants.ts index d5fffa74..26f90570 100644 --- a/packages/cli/src/commands/db/migrations/constants.ts +++ b/packages/cli/src/commands/db/migrations/constants.ts @@ -4,8 +4,14 @@ export const DEFAULT_MIGRATIONS_DIR = "migrations"; /** Directories checked when `--dir` is omitted and the default doesn't exist. */ export const FALLBACK_MIGRATIONS_DIRS = ["drizzle"] as const; +/** Default glob, relative to the migrations directory. Nested layouts opt in with `--pattern`. */ +export const DEFAULT_MIGRATIONS_PATTERN = "*.sql"; + /** Table recording applied migrations. The `__` prefix keeps it out of studio and REST introspection. */ export const MIGRATIONS_TABLE = "__bunny_migrations"; /** Flag name for overriding the migrations directory. */ export const ARG_DIR = "dir"; + +/** Flag name for overriding migration discovery within the migrations directory. */ +export const ARG_PATTERN = "pattern"; diff --git a/packages/cli/src/commands/db/migrations/create.ts b/packages/cli/src/commands/db/migrations/create.ts index 14d25e76..d21f1920 100644 --- a/packages/cli/src/commands/db/migrations/create.ts +++ b/packages/cli/src/commands/db/migrations/create.ts @@ -9,7 +9,7 @@ import { ARG_DIR } from "./constants.ts"; import { discoverMigrations, nextSequence, - resolveMigrationsDir, + resolveCreateMigrationsDir, slugify, } from "./engine.ts"; @@ -75,7 +75,7 @@ export const dbMigrationsCreateCommand = defineCommand({ } if (!name) throw new UserError("Migration name is required."); - const { dir } = resolveMigrationsDir(dirArg); + const dir = resolveCreateMigrationsDir(dirArg); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); diff --git a/packages/cli/src/commands/db/migrations/drift.ts b/packages/cli/src/commands/db/migrations/drift.ts index 630062e7..d54ac809 100644 --- a/packages/cli/src/commands/db/migrations/drift.ts +++ b/packages/cli/src/commands/db/migrations/drift.ts @@ -1,16 +1,43 @@ +import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import type { MigrationStatus } from "./engine.ts"; +const UNSAFE_STATES: ReadonlySet = new Set([ + "modified", + "missing", + "out_of_order", +]); + +/** History discrepancies that make applying more files ambiguous. */ +export function migrationHistoryIssues( + statuses: MigrationStatus[], +): MigrationStatus[] { + return statuses.filter((status) => UNSAFE_STATES.has(status.state)); +} + +/** Refuse to extend an ambiguous history unless the caller explicitly opts in. */ +export function assertMigrationHistorySafe( + statuses: MigrationStatus[], + allowDrift: boolean, +): void { + if (allowDrift || migrationHistoryIssues(statuses).length === 0) return; + + throw new UserError( + "Migration history needs attention; no migrations were applied.", + "Run `bunny db migrations list` for details. Restore or rename the affected files, or re-run with `--allow-drift` if this history is intentional.", + ); +} + /** * Warn when the files on disk no longer describe what the database has applied. * - * Both cases are reported rather than fatal: pending migrations can still be - * applied safely, and the fix (restore the file, or re-create the change as a - * new migration) is the developer's call. + * `list` always reports these states. `apply` prints the same detail before + * refusing to extend the history unless `--allow-drift` was explicit. */ export function warnOnDrift(statuses: MigrationStatus[]): void { const modified = statuses.filter((s) => s.state === "modified"); const missing = statuses.filter((s) => s.state === "missing"); + const outOfOrder = statuses.filter((s) => s.state === "out_of_order"); if (modified.length > 0) { logger.log(""); @@ -30,4 +57,15 @@ export function warnOnDrift(statuses: MigrationStatus[]): void { ); for (const s of missing) logger.dim(` ${s.name}`); } + + if (outOfOrder.length > 0) { + logger.log(""); + logger.warn( + `${outOfOrder.length} pending migration${outOfOrder.length === 1 ? " sorts" : "s sort"} before an already-applied migration:`, + ); + for (const s of outOfOrder) logger.dim(` ${s.name}`); + logger.dim( + " Applying it now would make the recorded execution order differ from filename order.", + ); + } } diff --git a/packages/cli/src/commands/db/migrations/engine.test.ts b/packages/cli/src/commands/db/migrations/engine.test.ts index ad76a42e..f8184264 100644 --- a/packages/cli/src/commands/db/migrations/engine.test.ts +++ b/packages/cli/src/commands/db/migrations/engine.test.ts @@ -9,6 +9,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { createClient } from "@libsql/client"; +import { assertMigrationHistorySafe, migrationHistoryIssues } from "./drift.ts"; import { applyMigration, checksum, @@ -16,11 +17,13 @@ import { ensureMigrationsTable, fetchApplied, type MigrationClient, + migrationStatements, migrationStatuses, migrationsTableExists, nextSequence, pendingMigrations, readApplied, + resolveCreateMigrationsDir, resolveMigrationsDir, slugify, } from "./engine.ts"; @@ -69,6 +72,40 @@ describe("discoverMigrations", () => { ]); }); + test("supports nested layouts through a relative glob", () => { + mkdirSync(join(dir, "0002_second")); + mkdirSync(join(dir, "0001_first")); + writeFileSync(join(dir, "0002_second", "migration.sql"), "SELECT 2;"); + writeFileSync(join(dir, "0001_first", "migration.sql"), "SELECT 1;"); + write("README.md", "not sql"); + + expect( + discoverMigrations(dir, "*/migration.sql").map((file) => file.name), + ).toEqual(["0001_first/migration.sql", "0002_second/migration.sql"]); + }); + + test("can combine top-level and nested SQL with a recursive glob", () => { + write("0001_first.sql", "SELECT 1;"); + mkdirSync(join(dir, "0002_second")); + writeFileSync(join(dir, "0002_second", "migration.sql"), "SELECT 2;"); + + expect( + discoverMigrations(dir, "**/*.sql").map((file) => file.name), + ).toEqual(["0001_first.sql", "0002_second/migration.sql"]); + }); + + test("rejects patterns that can escape the migrations directory", () => { + expect(() => discoverMigrations(dir, "../*.sql")).toThrow( + /Invalid migration pattern/, + ); + expect(() => discoverMigrations(dir, "/tmp/*.sql")).toThrow( + /Invalid migration pattern/, + ); + expect(() => discoverMigrations(dir, "!*.sql")).toThrow( + /Invalid migration pattern/, + ); + }); + test("throws a hinted error when the directory is missing", () => { expect(() => discoverMigrations(join(dir, "nope"))).toThrow( /Migrations directory not found/, @@ -173,6 +210,13 @@ describe("resolveMigrationsDir", () => { detected: false, }); }); + + test("create never auto-detects an ORM directory", () => { + process.chdir(dir); + mkdirSync(join(dir, "drizzle")); + expect(resolveCreateMigrationsDir()).toBe(join(dir, "migrations")); + expect(resolveCreateMigrationsDir("custom")).toBe(join(dir, "custom")); + }); }); describe("migrationStatuses", () => { @@ -219,6 +263,58 @@ describe("migrationStatuses", () => { }, ]); }); + + test("marks a late-arriving file as out of order", () => { + write("0001_late.sql", "SELECT 1;"); + write("0002_applied.sql", "SELECT 2;"); + const files = discoverMigrations(dir); + + expect( + migrationStatuses(files, [ + { + name: "0002_applied.sql", + checksum: checksum("SELECT 2;"), + applied_at: "now", + }, + ]), + ).toEqual([ + { name: "0001_late.sql", state: "out_of_order" }, + { + name: "0002_applied.sql", + state: "applied", + appliedAt: "now", + }, + ]); + }); +}); + +describe("migration history safety", () => { + test("blocks modified, missing, and out-of-order histories", () => { + const statuses = [ + { name: "0001.sql", state: "modified" as const }, + { name: "0002.sql", state: "missing" as const }, + { name: "0000.sql", state: "out_of_order" as const }, + { name: "0003.sql", state: "pending" as const }, + ]; + + expect(migrationHistoryIssues(statuses)).toHaveLength(3); + expect(() => assertMigrationHistorySafe(statuses, false)).toThrow( + /Migration history needs attention/, + ); + expect(() => assertMigrationHistorySafe(statuses, true)).not.toThrow(); + }); + + test("accepts an ordinary applied and pending history", () => { + expect(() => + assertMigrationHistorySafe( + [ + { name: "0001.sql", state: "applied" }, + { name: "0002.sql", state: "pending" }, + ], + false, + ), + ).not.toThrow(); + }); }); describe("pendingMigrations", () => { @@ -334,6 +430,22 @@ describe("applyMigration", () => { expect(cols.rows).toHaveLength(1); }); + test("applies valid SQL containing semicolons in quoted identifiers", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write( + "0001_quoted.sql", + 'CREATE TABLE "semi;colon" (id INTEGER); INSERT INTO "semi;colon" VALUES (1);', + ); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await applyMigration(client, file); + const rows = await client.execute('SELECT id FROM "semi;colon"'); + expect(rows.rows).toHaveLength(1); + }); + test("rejects a file with no statements", async () => { const client = memoryClient(); await ensureMigrationsTable(client); @@ -346,6 +458,37 @@ describe("applyMigration", () => { /No SQL statements found/, ); }); + + test("reports parser errors with the migration filename", () => { + write("0001_truncated.sql", "SELECT 1; /* never closed"); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + expect(() => migrationStatements(file)).toThrow( + /Could not parse 0001_truncated.sql: Unterminated block comment/, + ); + }); + + test("does not write or record a lexically invalid migration", async () => { + const client = memoryClient(); + await ensureMigrationsTable(client); + + write( + "0001_truncated.sql", + "CREATE TABLE should_not_exist (id INTEGER); /* never closed", + ); + const [file] = discoverMigrations(dir); + if (!file) throw new Error("no migration discovered"); + + await expect(applyMigration(client, file)).rejects.toThrow( + /Unterminated block comment/, + ); + expect(await fetchApplied(client)).toEqual([]); + const table = await client.execute( + "SELECT name FROM sqlite_master WHERE name = 'should_not_exist'", + ); + expect(table.rows).toHaveLength(0); + }); }); describe("migrationsTableExists", () => { diff --git a/packages/cli/src/commands/db/migrations/engine.ts b/packages/cli/src/commands/db/migrations/engine.ts index a1b32189..2c1e1635 100644 --- a/packages/cli/src/commands/db/migrations/engine.ts +++ b/packages/cli/src/commands/db/migrations/engine.ts @@ -1,11 +1,12 @@ import { createHash } from "node:crypto"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; import { splitStatements } from "@bunny.net/database-shell"; import type { Client } from "@libsql/client"; import { errorMessage, UserError } from "../../../core/errors.ts"; import { DEFAULT_MIGRATIONS_DIR, + DEFAULT_MIGRATIONS_PATTERN, FALLBACK_MIGRATIONS_DIRS, MIGRATIONS_TABLE, } from "./constants.ts"; @@ -27,12 +28,17 @@ export interface AppliedMigration { applied_at: string; } -export type MigrationState = "applied" | "pending" | "modified" | "missing"; +export type MigrationState = + | "applied" + | "pending" + | "modified" + | "missing" + | "out_of_order"; export interface MigrationStatus { name: string; state: MigrationState; - /** Set for every state except `pending`. */ + /** Set for states backed by an applied tracking row. */ appliedAt?: string; } @@ -76,18 +82,31 @@ export function resolveMigrationsDir(dirArg?: string): { return { dir: resolve(DEFAULT_MIGRATIONS_DIR), detected: false }; } +/** + * Pick the directory used by `migrations create`. + * + * Creation never auto-detects an ORM output directory: writing a hand-authored + * file there would bypass the ORM's journal. An explicit `--dir` still wins. + */ +export function resolveCreateMigrationsDir(dirArg?: string): string { + return resolve(dirArg ?? DEFAULT_MIGRATIONS_DIR); +} + function isDirectory(path: string): boolean { return existsSync(path) && statSync(path).isDirectory(); } /** - * Read every `.sql` file in `dir`, sorted by filename. + * Read every `.sql` file matching `pattern` in `dir`, sorted by relative path. * - * Filenames are the migration identity, so the numeric prefix written by - * `db migrations create` (and by `drizzle-kit generate`) determines order. - * Subdirectories are ignored, which skips `drizzle/meta/`. + * The portable, slash-separated relative path is the migration identity. The + * default pattern only considers top-level files; `--pattern` opts into nested + * ORM layouts without teaching the runner about ORM-specific journals. */ -export function discoverMigrations(dir: string): MigrationFile[] { +export function discoverMigrations( + dir: string, + pattern = DEFAULT_MIGRATIONS_PATTERN, +): MigrationFile[] { if (!isDirectory(dir)) { throw new UserError( `Migrations directory not found: ${dir}`, @@ -95,21 +114,63 @@ export function discoverMigrations(dir: string): MigrationFile[] { ); } - const files: MigrationFile[] = []; + validateMigrationPattern(pattern); - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (entry.name.startsWith(".")) continue; - if (!entry.name.endsWith(".sql")) continue; + const files: MigrationFile[] = []; - const path = join(dir, entry.name); - const sql = readFileSync(path, "utf-8"); - files.push({ name: entry.name, path, sql, checksum: checksum(sql) }); + try { + const glob = new Bun.Glob(pattern); + for (const match of glob.scanSync({ + cwd: dir, + dot: false, + absolute: false, + followSymlinks: false, + onlyFiles: true, + })) { + if (!match.endsWith(".sql")) continue; + + const path = resolve(dir, match); + const relativePath = relative(dir, path); + if ( + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new UserError( + `Migration pattern must stay inside the migrations directory: ${pattern}`, + ); + } + + const name = relativePath.replaceAll("\\", "/"); + const sql = readFileSync(path, "utf-8"); + files.push({ name, path, sql, checksum: checksum(sql) }); + } + } catch (err: unknown) { + if (err instanceof UserError) throw err; + throw new UserError( + `Could not discover migrations with pattern ${pattern}: ${errorMessage(err)}`, + ); } return files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } +function validateMigrationPattern(pattern: string): void { + const portable = pattern.replaceAll("\\", "/"); + if ( + !pattern.trim() || + pattern.startsWith("!") || + isAbsolute(pattern) || + /^[A-Za-z]:\//.test(portable) || + portable.split("/").includes("..") + ) { + throw new UserError( + `Invalid migration pattern: ${pattern}`, + "Use a positive glob relative to the migrations directory, such as `*.sql` or `*/migration.sql`.", + ); + } +} + /** Next zero-padded sequence number, one above the highest numeric prefix present. */ export function nextSequence(files: MigrationFile[]): string { let highest = 0; @@ -216,10 +277,22 @@ export function migrationStatuses( applied: AppliedMigration[], ): MigrationStatus[] { const byName = new Map(applied.map((row) => [row.name, row])); + const newestApplied = applied.reduce( + (newest, row) => (row.name > newest ? row.name : newest), + "", + ); const statuses: MigrationStatus[] = files.map((file) => { const record = byName.get(file.name); - if (!record) return { name: file.name, state: "pending" }; + if (!record) { + return { + name: file.name, + state: + newestApplied && file.name < newestApplied + ? "out_of_order" + : "pending", + }; + } return { name: file.name, state: record.checksum === file.checksum ? "applied" : "modified", @@ -249,6 +322,25 @@ export function pendingMigrations( return files.filter((file) => !byName.has(file.name)); } +/** Parse and validate a migration before any database write occurs. */ +export function migrationStatements(file: MigrationFile): string[] { + let statements: string[]; + try { + statements = splitStatements(file.sql); + } catch (err: unknown) { + throw new UserError( + `Could not parse ${file.name}: ${errorMessage(err)}`, + "Fix the migration file before applying any pending migrations.", + ); + } + + if (statements.length === 0) { + throw new UserError(`No SQL statements found in ${file.name}.`); + } + + return statements; +} + /** * Apply one migration. * @@ -260,13 +352,10 @@ export function pendingMigrations( export async function applyMigration( client: MigrationClient, file: MigrationFile, - table = MIGRATIONS_TABLE, + options: { table?: string; statements?: string[] } = {}, ): Promise<{ statements: number }> { - const statements = splitStatements(file.sql); - - if (statements.length === 0) { - throw new UserError(`No SQL statements found in ${file.name}.`); - } + const table = options.table ?? MIGRATIONS_TABLE; + const statements = options.statements ?? migrationStatements(file); await client.migrate([ ...statements.map((sql) => ({ sql })), diff --git a/packages/cli/src/commands/db/migrations/list.ts b/packages/cli/src/commands/db/migrations/list.ts index 81bae4e3..6c00d1e9 100644 --- a/packages/cli/src/commands/db/migrations/list.ts +++ b/packages/cli/src/commands/db/migrations/list.ts @@ -3,12 +3,18 @@ import { defineCommand } from "../../../core/define-command.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { ARG_DATABASE_ID } from "../constants.ts"; -import { resolveCredentials } from "../credentials.ts"; -import { ARG_DIR, MIGRATIONS_TABLE } from "./constants.ts"; -import { warnOnDrift } from "./drift.ts"; +import { databaseTarget, resolveCredentials } from "../credentials.ts"; +import { + ARG_DIR, + ARG_PATTERN, + DEFAULT_MIGRATIONS_PATTERN, + MIGRATIONS_TABLE, +} from "./constants.ts"; +import { migrationHistoryIssues, warnOnDrift } from "./drift.ts"; import { discoverMigrations, migrationStatuses, + pendingMigrations, readApplied, resolveMigrationsDir, } from "./engine.ts"; @@ -25,11 +31,13 @@ const STATE_LABELS = { pending: "Pending", modified: "Modified", missing: "Missing", + out_of_order: "Out of order", } as const; interface ListArgs { [ARG_DATABASE_ID]?: string; [ARG_DIR]?: string; + [ARG_PATTERN]?: string; [ARG_URL]?: string; [ARG_TOKEN]?: string; } @@ -63,6 +71,11 @@ export const dbMigrationsListCommand = defineCommand({ type: "string", describe: "Migrations directory (default: migrations)", }) + .option(ARG_PATTERN, { + type: "string", + default: DEFAULT_MIGRATIONS_PATTERN, + describe: "Migration glob relative to --dir", + }) .option(ARG_URL, { type: "string", describe: "Database URL (skips API lookup)", @@ -75,6 +88,7 @@ export const dbMigrationsListCommand = defineCommand({ handler: async ({ [ARG_DATABASE_ID]: databaseIdArg, [ARG_DIR]: dirArg, + [ARG_PATTERN]: pattern = DEFAULT_MIGRATIONS_PATTERN, [ARG_URL]: urlArg, [ARG_TOKEN]: tokenArg, profile, @@ -83,14 +97,14 @@ export const dbMigrationsListCommand = defineCommand({ apiKey, }) => { const { dir, detected } = resolveMigrationsDir(dirArg); - const files = discoverMigrations(dir); + const files = discoverMigrations(dir, pattern); const displayDir = relative(process.cwd(), dir) || "."; if (detected && output !== "json") { logger.dim(`Using ${displayDir}`); } - const { url, token } = await resolveCredentials({ + const { url, token, databaseId } = await resolveCredentials({ url: urlArg, token: tokenArg, databaseId: databaseIdArg, @@ -101,6 +115,9 @@ export const dbMigrationsListCommand = defineCommand({ const { createClient } = await import("@libsql/client/web"); const client = createClient({ url, authToken: token }); + const target = databaseTarget(url, databaseId); + + if (output !== "json") logger.dim(`Database: ${target.label}`); // Don't create the tracking table from a read-only command. const applied = await readApplied(client); @@ -112,7 +129,12 @@ export const dbMigrationsListCommand = defineCommand({ JSON.stringify( { dir: displayDir, + pattern, table: MIGRATIONS_TABLE, + target: { + database_id: target.databaseId, + host: target.host, + }, migrations: statuses.map((s) => ({ name: s.name, state: s.state, @@ -128,7 +150,15 @@ export const dbMigrationsListCommand = defineCommand({ if (statuses.length === 0) { logger.info(`No migrations found in ${displayDir}.`); - logger.dim("Run `bunny db migrations create ` to add one."); + const nested = + pattern === DEFAULT_MIGRATIONS_PATTERN + ? discoverMigrations(dir, "**/*.sql") + : []; + logger.dim( + nested.length > 0 + ? 'Nested SQL files were found. Pass a matching glob such as `--pattern "*/migration.sql"`.' + : "Run `bunny db migrations create ` to add one.", + ); return; } @@ -144,11 +174,14 @@ export const dbMigrationsListCommand = defineCommand({ ), ); - const pending = statuses.filter((s) => s.state === "pending").length; + const pending = pendingMigrations(files, applied).length; + const issues = migrationHistoryIssues(statuses).length; logger.log(""); logger.dim( pending === 0 - ? "Up to date." + ? issues === 0 + ? "Up to date." + : "No pending migrations; history needs attention." : `${pending} pending. Run \`bunny db migrations apply\` to apply ${pending === 1 ? "it" : "them"}.`, ); diff --git a/packages/database-shell/src/parser.ts b/packages/database-shell/src/parser.ts index 2bd44586..2c4e03b0 100644 --- a/packages/database-shell/src/parser.ts +++ b/packages/database-shell/src/parser.ts @@ -2,7 +2,17 @@ const BLOCK_BODY_START = /^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i; /** Quoted strings and identifiers, so keywords inside them don't affect nesting. */ -const QUOTED = /'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]/g; +const QUOTED = /'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]/g; + +type QuoteTerminator = "'" | '"' | "`" | "]"; + +function syntaxError(sql: string, offset: number, message: string): Error { + const before = sql.slice(0, offset); + const line = (before.match(/\n/g)?.length ?? 0) + 1; + const lastNewline = before.lastIndexOf("\n"); + const column = offset - lastNewline; + return new Error(`${message} at line ${line}, column ${column}.`); +} /** * True when `current` opens a `BEGIN ... END` block that hasn't been closed yet. @@ -24,9 +34,11 @@ function inBlockBody(current: string): boolean { } /** - * Split a SQL string into individual statements, handling single-quoted strings - * and both `--` line and block comments. Trims whitespace and filters empty - * results. Comments are dropped, so a `;` or a quote inside one is inert. + * Split a SQL string into individual statements, handling SQLite strings, + * quoted identifiers, and both `--` line and block comments. Trims whitespace + * and filters empty results. Comments are dropped, so a `;` or quote inside one + * is inert. Unterminated quotes and block comments are rejected instead of + * silently truncating a migration. * * `CREATE TRIGGER` bodies are kept intact: semicolons inside `BEGIN ... END` * don't split the statement. @@ -34,14 +46,32 @@ function inBlockBody(current: string): boolean { export function splitStatements(sql: string): string[] { const statements: string[] = []; let current = ""; - let inString = false; + let quote: QuoteTerminator | undefined; + let quoteStart = -1; for (let i = 0; i < sql.length; i++) { const ch = sql[i]; if (ch === undefined) break; - // Handle -- line comments (only outside strings) - if (!inString && ch === "-" && sql[i + 1] === "-") { + if (quote) { + current += ch; + if (ch !== quote) continue; + + // SQLite escapes string, double-quote, and backtick delimiters by + // doubling them. Bracket identifiers end at the first closing bracket. + if (quote !== "]" && sql[i + 1] === quote) { + current += quote; + i++; + continue; + } + + quote = undefined; + quoteStart = -1; + continue; + } + + // Handle -- line comments (only outside quotes) + if (ch === "-" && sql[i + 1] === "-") { const nl = sql.indexOf("\n", i); if (nl === -1) break; i = nl; @@ -49,32 +79,32 @@ export function splitStatements(sql: string): string[] { continue; } - // Handle /* */ block comments (only outside strings) - if (!inString && ch === "/" && sql[i + 1] === "*") { + // Handle /* */ block comments (only outside quotes) + if (ch === "/" && sql[i + 1] === "*") { const close = sql.indexOf("*/", i + 2); - if (close === -1) break; + if (close === -1) { + throw syntaxError(sql, i, "Unterminated block comment"); + } i = close + 1; current += " "; continue; } - if (ch === "'") { - if (inString) { - // '' is an escaped quote inside a string, not end of string - if (sql[i + 1] === "'") { - current += "''"; - i++; - continue; - } - inString = false; - } else { - inString = true; - } + if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + quoteStart = i; current += ch; continue; } - if (ch === ";" && !inString) { + if (ch === "[") { + quote = "]"; + quoteStart = i; + current += ch; + continue; + } + + if (ch === ";") { if (inBlockBody(current)) { current += ch; continue; @@ -88,6 +118,10 @@ export function splitStatements(sql: string): string[] { current += ch; } + if (quote) { + throw syntaxError(sql, quoteStart, "Unterminated quoted value"); + } + const trimmed = current.trim(); if (trimmed.length > 0) statements.push(trimmed); diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index 906bc6b1..3ab51d8a 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -549,6 +549,26 @@ describe("splitStatements", () => { ]); }); + test("preserves semicolons inside quoted identifiers", () => { + expect( + splitStatements( + 'CREATE TABLE "double;quote" (id INT); CREATE TABLE `back;tick` (id INT); CREATE TABLE [bracket;name] (id INT);', + ), + ).toEqual([ + 'CREATE TABLE "double;quote" (id INT)', + "CREATE TABLE `back;tick` (id INT)", + "CREATE TABLE [bracket;name] (id INT)", + ]); + }); + + test("handles escaped quoted-identifier delimiters", () => { + expect( + splitStatements( + 'CREATE TABLE "double""quote;name" (id INT); CREATE TABLE `back``tick;name` (id INT);', + ), + ).toHaveLength(2); + }); + test("handles multiple statements with embedded semicolons", () => { const sql = "INSERT INTO t VALUES ('x;y');\nSELECT * FROM t WHERE name = 'a;b';"; @@ -612,8 +632,19 @@ describe("splitStatements", () => { expect(splitStatements(sql)[0]?.endsWith("END")).toBe(true); }); - test("stops at an unterminated block comment without losing the statement", () => { - expect(splitStatements("SELECT 1; /* never closed")).toEqual(["SELECT 1"]); + test("rejects an unterminated block comment instead of truncating the file", () => { + expect(() => splitStatements("SELECT 1; /* never closed")).toThrow( + /Unterminated block comment at line 1, column 11/, + ); + }); + + test("rejects unterminated strings and quoted identifiers", () => { + expect(() => splitStatements("SELECT 'never closed")).toThrow( + /Unterminated quoted value/, + ); + expect(() => splitStatements('CREATE TABLE "never;closed')).toThrow( + /Unterminated quoted value/, + ); }); test("keeps a trigger body whose statement ends in CASE ... END intact", () => { diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index ab7d0b49..16f295d4 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -35,6 +35,7 @@ bunny db create bunny db list bunny db shell bunny db migrations apply # run pending migrations/*.sql files +bunny db migrations apply --pattern "*/migration.sql" # opt into a nested ORM layout # manage Edge Scripts bunny scripts init diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index 25ed8d14..d204db8a 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -214,7 +214,7 @@ Spins up a local server, generates a short-lived auth token if needed, and opens ## `bunny db migrations` — Create and apply SQL migrations -Migrations are plain `.sql` files in `migrations/`, named `NNNN_.sql`. The filename is the migration's identity and its numeric prefix is the apply order. Applied migrations are recorded in a `__bunny_migrations` table in the database. There is no rollback: fix a bad migration with another migration. +Migrations are plain `.sql` files in `migrations/`, named `NNNN_.sql`. The filename (or relative path for a nested layout) is the migration's identity and its numeric prefix is the apply order. Applied migrations are recorded in a `__bunny_migrations` table in the database. There is no rollback: fix a bad migration with another migration. ```bash bunny db migrations create add_users_table # writes migrations/0001_add_users_table.sql @@ -222,6 +222,7 @@ bunny db migrations list # applied / pending / modified / mi bunny db migrations apply --dry-run # show what would run bunny db migrations apply # apply pending migrations in order bunny db migrations apply --dir drizzle # apply drizzle-kit generate output +bunny db migrations apply --pattern "*/migration.sql" # apply a nested ORM layout ``` ### `bunny db migrations create ` (alias: `new`) @@ -234,37 +235,47 @@ Numbers the file one above the highest existing prefix and slugifies the name. C ### `bunny db migrations list` (aliases: `ls`, `status`) -| Flag | Default | Description | -| --------- | ------------ | ----------------------------------- | -| `--dir` | `migrations` | Migrations directory | -| `--url` | | Database URL (skips API lookup) | -| `--token` | | Auth token (skips token generation) | +| Flag | Default | Description | +| ----------- | ------------ | ------------------------------------------------ | +| `--dir` | `migrations` | Migrations directory | +| `--pattern` | `*.sql` | Migration glob relative to the migrations folder | +| `--url` | | Database URL (skips API lookup) | +| `--token` | | Auth token (skips token generation) | -Never creates the tracking table, so it is safe against a database that has never had a migration applied. States are `Applied`, `Pending`, `Modified` (the file changed after being applied), and `Missing` (the file was deleted). Modified and missing are warnings, not errors. +Never creates the tracking table, so it is safe against a database that has never had a migration applied. States are `Applied`, `Pending`, `Modified` (the file changed after being applied), `Missing` (the file was deleted), and `Out of order` (a new file sorts before an applied one). The database ID and host are shown without credentials. ### `bunny db migrations apply` -| Flag | Short | Default | Description | -| ----------- | ----- | ------------ | ------------------------------------ | -| `--dir` | | `migrations` | Migrations directory | -| `--dry-run` | | `false` | List what would run without applying | -| `--force` | `-f` | `false` | Skip the confirmation prompt | -| `--url` | | | Database URL (skips API lookup) | -| `--token` | | | Auth token (skips token generation) | +| Flag | Short | Default | Description | +| --------------- | ----- | ------------ | --------------------------------------------------- | +| `--dir` | | `migrations` | Migrations directory | +| `--pattern` | | `*.sql` | Migration glob relative to the migrations folder | +| `--dry-run` | | `false` | List what would run without applying | +| `--force` | `-f` | `false` | Skip the confirmation prompt | +| `--allow-drift` | | `false` | Apply despite modified, missing, or late migrations | +| `--url` | | | Database URL (skips API lookup) | +| `--token` | | | Auth token (skips token generation) | -Each file runs as one atomic batch together with its tracking row, so a migration either lands and is recorded or neither happens. Foreign keys are deferred for the duration, so table rebuilds and `ALTER TABLE` work. The run stops at the first failure and leaves the rest pending. +Each file runs as one atomic batch together with its tracking row, so a migration either lands and is recorded or neither happens. Foreign keys are deferred for the duration, so table rebuilds and `ALTER TABLE` work. Every pending file is parsed before the first write. The run stops at the first database failure and leaves the rest pending. + +Modified, missing, and out-of-order histories are shown by `list` but block `apply` when more migrations are pending. Restore or rename the affected files; use `--allow-drift` only when the divergence is intentional. Confirms before writing when a TTY is attached; the prompt is skipped under `--force`, `--output json`, or any non-interactive run, so CI and agent flows aren't blocked. Credential resolution mirrors `db shell`. ### ORM-generated migrations -`drizzle-kit generate` writes flat `0000_.sql` files, which match this convention. When `migrations/` doesn't exist, `drizzle/` is used automatically. Generate with the ORM, apply with the CLI: +Flat `drizzle-kit generate` output uses `0000_.sql`, which matches this convention. When `migrations/` doesn't exist, `drizzle/` is used automatically by `list` and `apply`. For nested layouts, set a glob relative to `--dir`: ```bash drizzle-kit generate bunny db migrations apply +bunny db migrations apply --dir migrations --pattern "*/migration.sql" ``` +`db migrations create` always defaults to `migrations/` and only writes top-level files; it never silently writes into an auto-detected ORM directory. Use the ORM's own generator when it owns the schema. + +Choose one migration runner for each history. Bunny records paths in `__bunny_migrations` and does not read or update Drizzle, Prisma, dbmate, or other tools' journals. Existing tools remain supported by running their own migration command instead of alternating runners over the same files. + --- ## `bunny db quickstart` — Language-specific getting-started guide From 162194a91d74dab80439a3724cd62c8219a1a028 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 13 Aug 2026 14:22:34 +0100 Subject: [PATCH 07/10] lint and fmt --- AGENTS.md | 238 +++++++++++++++++++++++++++--------------------------- README.md | 2 +- 2 files changed, 120 insertions(+), 120 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b46b171..61411ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md: bunny.net CLI -This document describes the architecture, conventions, and implementation details for the bunny.net CLI. It serves as the canonical reference for AI agents and contributors working on this codebase. +This document describes the architecture, conventions, and implementation details for the bunny.net CLI. It is the canonical reference for AI agents and contributors working on this codebase. --- @@ -58,12 +58,12 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans ### Packages we explicitly do NOT use -- **No `dotenv`** — Bun loads `.env` automatically. -- **No `execa`** — Use `Bun.spawn()` or `Bun.$` shell. -- **No `express` or `http`** — Use `Bun.serve()` for HTTP servers. -- **No `ink` or `react`** — We use the lighter stack of `ora` + `prompts` + `chalk`. -- **No `commander` or `clipanion`** — We use `yargs`. -- **No `cosmiconfig`** — Config file resolution is hand-rolled to match the existing Go CLI behavior. +- **No `dotenv`**: Bun loads `.env` automatically. +- **No `execa`**: use `Bun.spawn()` or `Bun.$` shell. +- **No `express` or `http`**: use `Bun.serve()` for HTTP servers. +- **No `ink` or `react`**: we use the lighter stack of `ora` + `prompts` + `chalk`. +- **No `commander` or `clipanion`**: we use `yargs`. +- **No `cosmiconfig`**: config file resolution is hand-rolled to match the existing Go CLI behavior. --- @@ -71,12 +71,12 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans This is a Bun workspace monorepo with six packages: -- **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. -- **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. -- **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). +- **`@bunny.net/openapi-client`** (`packages/openapi-client/`): standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. +- **`@bunny.net/config`** (`packages/config/`): shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. +- **`@bunny.net/database-shell`** (`packages/database-shell/`): standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. -- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. -- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. +- **`@bunny.net/sandbox`** (`packages/sandbox/`): standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. +- **`@bunny.net/cli`** (`packages/cli/`): the CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. ``` bunny-cli/ @@ -86,29 +86,29 @@ bunny-cli/ │ │ ├── tsconfig.json │ │ ├── redocly.yaml # Multi-spec config for openapi-typescript │ │ ├── specs/ # OpenAPI specs (committed, JSON) -│ │ │ ├── core.json # Core API — https://api.bunny.net -│ │ │ ├── compute.json # Edge Scripting API — https://api.bunny.net/compute -│ │ │ ├── database.json # Database API — https://api.bunny.net/database -│ │ │ ├── magic-containers.json # Magic Containers API — https://api.bunny.net/mc -│ │ │ ├── origin-errors.json # Origin Errors API — https://cdn-origin-logging.bunny.net -│ │ │ ├── shield.json # Shield API — https://api.bunny.net (paths under /shield/...) -│ │ │ ├── storage.json # Edge Storage API — https://storage.bunnycdn.com (region-specific) -│ │ │ └── stream.json # Stream API — https://video.bunnycdn.com +│ │ │ ├── core.json # Core API at https://api.bunny.net +│ │ │ ├── compute.json # Edge Scripting API at https://api.bunny.net/compute +│ │ │ ├── database.json # Database API at https://api.bunny.net/database +│ │ │ ├── magic-containers.json # Magic Containers API at https://api.bunny.net/mc +│ │ │ ├── origin-errors.json # Origin Errors API at https://cdn-origin-logging.bunny.net +│ │ │ ├── shield.json # Shield API at https://api.bunny.net (paths under /shield/...) +│ │ │ ├── storage.json # Edge Storage API at https://storage.bunnycdn.com (region-specific) +│ │ │ └── stream.json # Stream API at https://video.bunnycdn.com │ │ ├── scripts/ │ │ │ └── update-specs.ts # Downloads latest specs from bunny.net endpoints │ │ └── src/ │ │ ├── index.ts # Barrel export: clients, errors, ClientOptions type, DNS scan type corrections -│ │ ├── middleware.ts # authMiddleware(options) — dependency-inverted (no CLI imports) +│ │ ├── middleware.ts # authMiddleware(options), dependency-inverted (no CLI imports) │ │ ├── errors.ts # UserError, ApiError classes │ │ ├── dns.ts # Hand-authored corrections for lossy generated DNS types: DnsDiscoveredRecord (adds Flags/Tag the scan returns but generation drops), DnsRecordScanJob/Trigger, DnsRecordScanStatus enum. Pattern for enriching generated types. -│ │ ├── core-client.ts # createCoreClient(options) — Core API -│ │ ├── compute-client.ts # createComputeClient(options) — Edge Scripting -│ │ ├── db-client.ts # createDbClient(options) — Database -│ │ ├── mc-client.ts # createMcClient(options) — Magic Containers -│ │ ├── origin-errors-client.ts # createOriginErrorsClient(options) — Origin Errors -│ │ ├── shield-client.ts # createShieldClient(options) — Shield (WAF/DDoS/bots) -│ │ ├── storage-client.ts # createStorageClient(options) — Edge Storage (region-specific) -│ │ ├── stream-client.ts # createStreamClient(options) — Stream (video libraries) +│ │ ├── core-client.ts # createCoreClient(options), Core API +│ │ ├── compute-client.ts # createComputeClient(options), Edge Scripting +│ │ ├── db-client.ts # createDbClient(options), Database +│ │ ├── mc-client.ts # createMcClient(options), Magic Containers +│ │ ├── origin-errors-client.ts # createOriginErrorsClient(options), Origin Errors +│ │ ├── shield-client.ts # createShieldClient(options), Shield (WAF/DDoS/bots) +│ │ ├── storage-client.ts # createStorageClient(options), Edge Storage (region-specific) +│ │ ├── stream-client.ts # createStreamClient(options), Stream (video libraries) │ │ └── generated/ # Generated .d.ts files (gitignored) │ │ ├── core.d.ts │ │ ├── compute.d.ts @@ -173,7 +173,7 @@ bunny-cli/ │ ├── cli.ts # Root yargs instance, global flags, command registration │ │ │ ├── core/ -│ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig +│ │ ├── client-options.ts # clientOptions() helper that builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees │ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): reads the parent zone's NS referral (raw UDP query of the registry, not the recursive answer a child host could spoof; falls back to dns.resolveNs when the referral is unreadable), matches the full expected set both ways, ground truth over bunny's NameserversDetected flag which defaults true on a fresh zone; checkDelegations is bounded-concurrency for the zone list @@ -210,9 +210,9 @@ bunny-cli/ │ │ └── paths.ts # XDG-compliant config file path resolution │ │ │ ├── commands/ -│ │ ├── apps/ # Experimental — hidden from help and landing page +│ │ ├── apps/ # Experimental: hidden from help and landing page │ │ │ ├── APPS.md # Apps documentation (while experimental) -│ │ │ ├── index.ts # defineNamespace("apps", false) — hidden, registers all app commands +│ │ │ ├── index.ts # defineNamespace("apps", false): hidden, registers all app commands │ │ │ ├── constants.ts # Status label maps + APP_MANIFEST filename + AppManifest interface (consumed via core/manifest.ts) │ │ │ ├── config.ts # bunny.jsonc app I/O over core/bunny-config.ts + core/jsonc.ts (loadConfig requires an `app` block; saveConfig strips transient `image`/`registry`/`app.id` via stripTransientFields and edits existing files surgically), re-exports from @bunny.net/config; provides resolveAppId, resolveContainerId, resolveContainerRegistry │ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, ensureRegistryLogin, dockerHasCredentials, ghDockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname, parseDockerfileExposedPorts/readDockerfileExposedPorts, findDockerfiles/isDockerfileName/defaultContainerNameFromDockerfile/assignContainerNamesToDockerfiles for monorepo Dockerfile discovery) @@ -264,16 +264,16 @@ bunny-cli/ │ │ │ ├── login.ts # Browser-based login via Bun.serve() callback (top-level: bunny login) │ │ │ └── logout.ts # Profile removal with --force confirmation bypass (top-level: bunny logout) │ │ ├── config/ -│ │ │ ├── index.ts # defineNamespace("config", ...) — registers init, show, profile +│ │ │ ├── index.ts # defineNamespace("config", ...) registers init, show, profile │ │ │ ├── init.ts # First-time setup (delegates to profile create) │ │ │ ├── show.ts # Display resolved config as table or JSON │ │ │ └── profile/ -│ │ │ ├── index.ts # defineNamespace("profile", ...) — registers create + delete +│ │ │ ├── index.ts # defineNamespace("profile", ...) registers create + delete │ │ │ ├── create.ts # Add profile with masked API key input │ │ │ └── delete.ts # Remove a profile │ │ ├── whoami.ts # Show authenticated account: name, email, profile (top-level: bunny whoami) │ │ ├── db/ -│ │ │ ├── index.ts # defineNamespace("db", ...) — registers all database commands +│ │ │ ├── index.ts # defineNamespace("db", ...) registers all database commands │ │ │ ├── constants.ts # Database status labels, region maps │ │ │ ├── api.ts # Shared: typed v2 database/token API calls (fetchDatabase, fetchAllDatabases, generateToken, fetchLiveStatus, …) │ │ │ ├── create.ts # Create a new database (interactive region selection or flags) @@ -291,7 +291,7 @@ bunny-cli/ │ │ │ ├── studio.ts # Open a visual database explorer in the browser (local web UI) │ │ │ ├── usage.ts # Show database usage statistics │ │ │ ├── migrations/ -│ │ │ │ ├── index.ts # defineNamespace("migrations", ...) — registers migration commands +│ │ │ │ ├── index.ts # defineNamespace("migrations", ...) registers migration commands │ │ │ │ ├── constants.ts # Default dir/pattern, drizzle fallback dir, tracking table name │ │ │ │ ├── engine.ts # Shared: glob discovery, checksums, applied/pending state, preflight parsing, apply one migration │ │ │ │ ├── drift.ts # Shared: report and block modified/missing/out-of-order histories unless explicitly allowed @@ -299,22 +299,22 @@ bunny-cli/ │ │ │ │ ├── create.ts # Write an empty numbered top-level migration file (never auto-detects ORM dirs) │ │ │ │ └── list.ts # Show applied/pending/modified/missing/out-of-order state │ │ │ ├── regions/ -│ │ │ │ ├── index.ts # defineNamespace("regions", ...) — registers region commands +│ │ │ │ ├── index.ts # defineNamespace("regions", ...) registers region commands │ │ │ │ ├── add.ts # Add primary/replica regions (interactive multiselect or flags) │ │ │ │ ├── list.ts # List configured primary and replica regions │ │ │ │ ├── remove.ts # Remove primary/replica regions │ │ │ │ └── update.ts # Interactive multiselect to toggle all regions on/off │ │ │ └── tokens/ -│ │ │ ├── index.ts # defineNamespace("tokens", ...) — registers token commands +│ │ │ ├── index.ts # defineNamespace("tokens", ...) registers token commands │ │ │ ├── create.ts # Generate an auth token (read-only/full-access, optional expiry) │ │ │ └── invalidate.ts # Invalidate all tokens for a database (with confirmation) │ │ ├── dns/ │ │ │ ├── index.ts # defineNamespace("dns", ...): registers the records + zones + scripts groups (+ hidden domain aliases) │ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone), scanZoneRecords (trigger + poll bunny's server-side record scan via /dnszone/records/scan; matches the triggered JobId, falling back to "differs from the prior job" when the trigger omits one; returns corrected DnsDiscoveredRecord[] with Flags/Tag; uses DnsRecordScanStatus enum) │ │ │ ├── constants.ts # DNS_MANIFEST (".bunny/dns.json") + DnsManifest type, written by `dns zones link` -│ │ │ ├── interactive.ts # resolveZoneInteractive (arg → .bunny/dns.json manifest → zone picker; errors instead of prompting when non-interactive (json output or no TTY, see core/ui.ts isInteractive); ignoreManifest forces the picker for `zones link`; offerLink prompts to link a picked zone) + resolveRecordInteractive; autoLinkDnsZone (link a zone found in another flow — silent write, confirm before relinking a different zone) reused by scripts custom-domain setup +│ │ │ ├── interactive.ts # resolveZoneInteractive (arg → .bunny/dns.json manifest → zone picker; errors instead of prompting when non-interactive (json output or no TTY, see core/ui.ts isInteractive); ignoreManifest forces the picker for `zones link`; offerLink prompts to link a picked zone) + resolveRecordInteractive; autoLinkDnsZone (link a zone found in another flow: silent write, confirm before relinking a different zone) reused by scripts custom-domain setup │ │ │ ├── record-types.ts # Re-exports RECORD_TYPES/RECORD_TYPE_META/recordTypeLabel from core/dns-record-types.ts; adds parseRecordType (accepts canonical labels + enum-key names), recordName, formatRecordValue -│ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec) +│ │ │ ├── record/ # `dns records`: entries within a zone (canonical: records; aliases: record, rec) │ │ │ │ ├── index.ts # defineNamespace("records", ...) │ │ │ │ ├── list.ts # List records in a zone (alias: ls) │ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactive wizard first offers "single record" vs a preset (pickAndApplyPreset); A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record. Exports addRecordInteractive (the single-record wizard) reused by zone/add.ts's next-steps menu @@ -329,7 +329,7 @@ bunny-cli/ │ │ │ │ ├── scan.ts # `records scan [domain]` (--yes): discover the domain's existing records (server-side scan) and reviewAndApply them; reused at zone creation │ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted). Exports importZoneFile reused by zone/add.ts's next-steps menu │ │ │ │ └── export.ts # Export records as a BIND zone file (stdout, --file , or --save → .zone) -│ │ │ └── zone/ # `dns zones` — the zone itself (canonical: zones; aliases: zone; hidden: domain, domains) +│ │ │ └── zone/ # `dns zones`: the zone itself (canonical: zones; aliases: zone; hidden: domain, domains) │ │ │ ├── index.ts # defineNamespace("zones", ...) + dnsZoneHiddenAliases (domain/domains) │ │ │ ├── list.ts # List all DNS zones (alias: ls); Nameservers column from a live per-zone NS lookup, not bunny's NameserversDetected flag │ │ │ ├── add.ts # Create a DNS zone (prompts for the domain when omitted; required non-interactively), then offerNextSteps menu (scan for existing records via scanAndImport: discoverImportableRecords + reviewAndApply / upload a zone file via importZoneFile / add records manually via addRecordInteractive / continue); --import scans and imports all without prompting and surfaces failures as a JSON ImportError + nonzero exit, --no-import skips the menu; then print the bunny nameservers (naming the registrar via core/registrar.ts when RDAP resolves it). Menu is TTY-gated so `zones add ` stays scriptable @@ -416,7 +416,7 @@ bunny-cli/ │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ -│ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list +│ │ │ ├── index.ts # Manual CommandModule (not defineNamespace); default handler runs list │ │ │ ├── list.ts # List container registries │ │ │ ├── add.ts # Add registry with credentials │ │ │ ├── update.ts # Update registry display name and/or rotate credentials @@ -424,7 +424,7 @@ bunny-cli/ │ │ ├── docs.ts # Open bunny.net documentation in browser (top-level: bunny docs) │ │ ├── open.ts # Open bunny.net dashboard in browser (top-level: bunny open) │ │ └── scripts/ -│ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands +│ │ ├── index.ts # defineNamespace("scripts", ...) registers all script commands │ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS │ │ ├── api.ts # Shared: fetchScript(s), fetchEnvEntries, fetchScriptHostnames, logLiveHostnames, promptOpenInBrowser │ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` + `setupCustomDomain`; for a linked script, setupCustomDomain auto-links the dir to the domain's Bunny DNS zone via autoLinkDnsZone) @@ -451,7 +451,7 @@ bunny-cli/ │ │ └── pull.ts # Pull environment variables to .env file │ │ │ ├── sandbox/ # `sandbox`: ephemeral dev sandboxes over @bunny.net/sandbox -│ │ ├── index.ts # defineNamespace("sandbox", ...) — registers all sandbox commands +│ │ ├── index.ts # defineNamespace("sandbox", ...) registers all sandbox commands │ │ ├── create.ts # Create a sandbox (--region, -e/--env + --env-file bake persisted env vars in) │ │ ├── list.ts # List sandboxes │ │ ├── delete.ts # Delete a sandbox and its MC app (--force) @@ -484,20 +484,20 @@ bunny-cli/ ### Conventions - **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. -- **API clients use `ClientOptions`** — an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. +- **API clients use `ClientOptions`**: an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. - **One command per file.** Each file in `commands/` exports a single command or namespace. - **Commands are grouped by domain** in subdirectories (`config/`, `db/`, `scripts/`). - **Namespaces are directories** with an `index.ts` that calls `defineNamespace()`. - **Leaf commands** are individual `.ts` files that call `defineCommand()`. - **Top-level commands** (`login`, `logout`, `whoami`) are registered directly in `cli.ts` without a namespace. -- **Shared internal code lives in `packages/cli/src/core/`** — command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this mostly flat; a cohesive, reusable feature spanning several files may use a subdirectory (e.g. `core/hostnames/` — the pull-zone hostname helpers + the `createHostnamesCommands` factory mounted by both `scripts` and, in future, `apps`). -- **Config logic lives in `packages/cli/src/config/`** — schema, file resolution, and profile management. +- **Shared internal code lives in `packages/cli/src/core/`**: command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this mostly flat; a cohesive, reusable feature spanning several files may use a subdirectory (e.g. `core/hostnames/`, the pull-zone hostname helpers + the `createHostnamesCommands` factory mounted by both `scripts` and, in future, `apps`). +- **Config logic lives in `packages/cli/src/config/`**: schema, file resolution, and profile management. - **Error classes are split.** `UserError` and `ApiError` live in `@bunny.net/openapi-client` (the SDK needs them). `ConfigError` lives in the CLI and extends `UserError`. The CLI's `errors.ts` re-exports `UserError` and `ApiError` from `@bunny.net/openapi-client`. - **Import API clients from `@bunny.net/openapi-client`**, not relative paths. Import generated types from `@bunny.net/openapi-client/generated/.d.ts`. - **Mask secrets in human output; reveal only behind an explicit flag.** Any sensitive value (API keys, passwords, S3 secret keys, auth tokens) must be masked in the default table/text output and shown in full only when the user opts in with a flag (e.g. `--show-secret`). Use `maskSecret()` from `core/format.ts` for the masked form (it keeps the last 4 characters for identification). Machine-readable output is the exception because it exists to be consumed by tools: `--output json` and tool-config `--format` emit full values. Never print a secret the user did not explicitly ask to see. Reference: `storage zones credentials` masks the S3 secret access key by default and reveals it with `--show-secret`, while never leaking it from inspect/list commands (see `toSafeStorageZone`). - **Pull-zone settings are exposed via "Hybrid D" across surfaces.** Scripts and apps are backed by a pull zone, which has a large settings surface (hostnames, caching, edge rules, origin, security, purge, CORS, optimizer, logging, …). To keep each owner's help legible: - - **Flatten only first-class groups** directly into the owner — picked by user mental model, kept to one or two. `scripts domains` is the flattened group (a custom domain is "my site's address," not a CDN setting). - - **Group the long tail** under a `pullzone` sub-namespace within the owner (e.g. `scripts pullzone `), so the owner's top-level help gains one line, not ten. Curate per owner — don't expose settings that don't apply (a script _is_ its pull zone's origin, so no origin-URL command under `scripts`). + - **Flatten only first-class groups** directly into the owner, picked by user mental model and kept to one or two. `scripts domains` is the flattened group (a custom domain is "my site's address," not a CDN setting). + - **Group the long tail** under a `pullzone` sub-namespace within the owner (e.g. `scripts pullzone `), so the owner's top-level help gains one line, not ten. Curate per owner and don't expose settings that don't apply (a script _is_ its pull zone's origin, so no origin-URL command under `scripts`). - **A standalone `bunny pullzone` command** (planned) is the canonical full surface for pull zones not backing a script/app, targeted by `--id`. - Each setting-area is a **mountable factory** like `createHostnamesCommands` (`core/hostnames/`): one `{ commandPath, target, targetPositional, resolve(args) => { pullZoneId, coreClient }, hiddenAliases }` mounted into the root `pullzone` (resolve from `--id`), `scripts` (resolve from the linked manifest), and `apps` (resolve from the CDN endpoint). The resolver is the only per-surface difference. `targetPositional` appends an optional trailing positional (e.g. `[id]`) to every subcommand so mounts can match their namespace's positional-ID convention; the flag form of the same key keeps working. - Canonical term is `pullzone` (matches the bunny.net dashboard/API); `pz` is a hidden alias (`defineNamespace(alias, false, …)`), the same pattern as `domains`'s hidden `hostnames` alias. @@ -586,11 +586,11 @@ Registered on the root yargs instance in `cli.ts` with `global: true` (equivalen These are configured on the root yargs instance: -- **`$0` default command** — Running `bunny` with no subcommand shows a branded landing page (ASCII art, commands list, examples, global options). -- **`recommendCommands()`** — "Did you mean ...?" suggestions on typos (like Cobra). -- **`strict()`** — Errors on unrecognized flags. -- **`.version()`** — Reads from `package.json`. -- **`.help()`** — Auto-generated help for all commands. +- **`$0` default command**: running `bunny` with no subcommand shows a branded landing page (ASCII art, commands list, examples, global options). +- **`recommendCommands()`**: "Did you mean ...?" suggestions on typos (like Cobra). +- **`strict()`**: errors on unrecognized flags. +- **`.version()`**: reads from `package.json`. +- **`.help()`**: auto-generated help for all commands. --- @@ -647,12 +647,12 @@ Config files are written with permissions `0o660`. ### Config resolution precedence -When resolving the active configuration (in `resolveConfig(profile, apiKeyOverride?, verbose?)`), the following priority applies — highest wins: +When resolving the active configuration (in `resolveConfig(profile, apiKeyOverride?, verbose?)`), the following priority applies (highest wins): -1. **`--api-key` flag** — Passed as `apiKeyOverride` to `resolveConfig()` -2. **Environment variables** — `BUNNYNET_API_KEY` and `BUNNYNET_API_URL` -3. **Config file profile** — Matched by the `--profile` flag value -4. **Built-in defaults** — `apiUrl: "https://api.bunny.net"`, empty `apiKey` +1. **`--api-key` flag**: passed as `apiKeyOverride` to `resolveConfig()` +2. **Environment variables**: `BUNNYNET_API_KEY` and `BUNNYNET_API_URL` +3. **Config file profile**: matched by the `--profile` flag value +4. **Built-in defaults**: `apiUrl: "https://api.bunny.net"`, empty `apiKey` If `--api-key` or `BUNNYNET_API_KEY` is set, the config file is ignored entirely and the profile field is set to `""`. @@ -707,10 +707,10 @@ An HTML page is embedded as a template literal string in `login.ts` (equivalent ### Profile management -- **`bunny config profile create `** (alias: `add`) — Prompts for API key with masked input, saves to config file. -- **`bunny config profile delete `** — Removes profile from config file. -- **`bunny config init`** — Convenience command that delegates to profile create for the active profile. -- **`bunny config show`** — Displays resolved config as a table (or JSON with `--output json`). API key is truncated in table view. +- **`bunny config profile create `** (alias: `add`): prompts for API key with masked input, saves to config file. +- **`bunny config profile delete `**: removes profile from config file. +- **`bunny config init`**: convenience command that delegates to profile create for the active profile. +- **`bunny config show`**: displays resolved config as a table (or JSON with `--output json`). API key is truncated in table view. --- @@ -736,9 +736,9 @@ Creates an `ora` spinner. Automatically silenced in non-TTY environments (`isSil ### Error classes -- **`UserError`** — Expected errors caused by user input or missing configuration. Displayed as a clean message with an optional hint. Exit code 1. -- **`ConfigError`** — Extends `UserError`. Automatically includes a hint to run `bunny config show`. -- **`ApiError`** — Extends `UserError`. Thrown by the API middleware for HTTP error responses. Carries `status`, optional `field`, and optional `validationErrors[]`. +- **`UserError`**: expected errors caused by user input or missing configuration. Displayed as a clean message with an optional hint. Exit code 1. +- **`ConfigError`**: extends `UserError`. Automatically includes a hint to run `bunny config show`. +- **`ApiError`**: extends `UserError`. Thrown by the API middleware for HTTP error responses. Carries `status`, optional `field`, and optional `validationErrors[]`. ### API error normalization @@ -806,16 +806,16 @@ Defined in `packages/cli/src/core/logger.ts`. Uses `chalk` for styling. | `logger.success(msg)` | `✓` (green) | Successful operations | | `logger.warn(msg)` | `⚠` (yellow) | Warnings | | `logger.error(msg)` | `✖` (red) | Errors | -| `logger.dim(msg)` | — (gray) | Hints, secondary info | +| `logger.dim(msg)` | - (gray) | Hints, secondary info | | `logger.debug(msg, verbose)` | `[debug]` (gray) | Only shown when `verbose` is `true` | ### NO_COLOR support The CLI respects the [NO_COLOR](https://no-color.org) standard. When `NO_COLOR` is set (any non-empty value), all ANSI color codes are suppressed: -- **chalk** — Natively respects `NO_COLOR` by setting `chalk.level` to `0`. -- **cli-table3** — Has its own built-in ANSI coloring for headers and borders. Disabled by passing `style: { head: [], border: [] }` when `chalk.level === 0`. This is handled in `format.ts` and `shell.ts`. -- **ora** — Uses chalk internally, so spinners are also affected. +- **chalk**: natively respects `NO_COLOR` by setting `chalk.level` to `0`. +- **cli-table3**: has its own built-in ANSI coloring for headers and borders. Disabled by passing `style: { head: [], border: [] }` when `chalk.level === 0`. This is handled in `format.ts` and `shell.ts`. +- **ora**: uses chalk internally, so spinners are also affected. --- @@ -918,19 +918,19 @@ Each release includes prebuilt binaries as release assets, created automatically ### Release workflow 1. Create changesets on feature branches (`bun run changeset`) -2. Merge to `main` — the `changesets/action` opens or updates a "Release" PR -3. Merge the Release PR — changesets bumps versions for `@bunny.net/cli` and all platform packages (kept in sync via `fixed`) +2. Merge to `main`; the `changesets/action` opens or updates a "Release" PR +3. Merge the Release PR; changesets bumps versions for `@bunny.net/cli` and all platform packages (kept in sync via `fixed`) 4. The release workflow detects the version change, builds binaries for all platforms, publishes platform packages then `@bunny.net/cli` to npm, and creates a GitHub release with binaries attached ### Publishing `@bunny.net/openapi-client` -Unlike the CLI and `database-shell` (which ship as compiled binaries), `@bunny.net/openapi-client` is published as a plain TypeScript library — compiled JS plus `.d.ts` declarations. +Unlike the CLI and `database-shell` (which ship as compiled binaries), `@bunny.net/openapi-client` is published as a plain TypeScript library: compiled JS plus `.d.ts` declarations. -Its `package.json` `exports`/`main`/`types` point at `dist/`, so npm consumers get the compiled output. **In-repo tooling resolves it from source instead** — the root `tsconfig.json` has a `paths` mapping for `@bunny.net/openapi-client` → `src/`, and `bun run`, `bun build --compile`, `bun test`, and `tsc` all honor `paths` over the package's `exports`. So the CLI build and dev loop consume live source with no prebuild step, while only the publish step needs `dist/` built. (Published consumers never see the repo `tsconfig.json`, so they fall back to `exports`.) +Its `package.json` `exports`/`main`/`types` point at `dist/`, so npm consumers get the compiled output. **In-repo tooling resolves it from source instead**: the root `tsconfig.json` has a `paths` mapping for `@bunny.net/openapi-client` → `src/`, and `bun run`, `bun build --compile`, `bun test`, and `tsc` all honor `paths` over the package's `exports`. So the CLI build and dev loop consume live source with no prebuild step, while only the publish step needs `dist/` built. (Published consumers never see the repo `tsconfig.json`, so they fall back to `exports`.) - `bun run --filter @bunny.net/openapi-client build` runs `generate` (the `src/generated/` types are gitignored, so they are regenerated from the committed specs), then `scripts/build.ts`. - `scripts/build.ts` drives the TypeScript compiler API (using `tsconfig.build.json`) to emit JS + declarations, then copies the generated `.d.ts` files into `dist/generated/` (tsc never emits its inputs, and those files back the `./generated/*` subpath export). `rewriteRelativeImportExtensions` rewrites `./x.ts` → `./x.js` in the emitted **JS**; TypeScript has no equivalent for declaration emit, so an `afterDeclarations` transformer rewrites the `.ts`/`.d.ts` specifiers in the emitted **`.d.ts`** files on the AST. -- The `publish-openapi-client` job in `release.yml` (gated on a version bump detected via `npm view`) builds, then runs `cd packages/openapi-client && npm publish` (`files` ships `dist` + `README.md`). The package versions independently of the CLI — it is not part of any `fixed` group in `.changeset/config.json`. +- The `publish-openapi-client` job in `release.yml` (gated on a version bump detected via `npm view`) builds, then runs `cd packages/openapi-client && npm publish` (`files` ships `dist` + `README.md`). The package versions independently of the CLI; it is not part of any `fixed` group in `.changeset/config.json`. ### Publishing `@bunny.net/sandbox` @@ -938,8 +938,8 @@ Its `package.json` `exports`/`main`/`types` point at `dist/`, so npm consumers g Two differences from openapi-client: -- Sandbox depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-sandbox` job in `release.yml` uses `bun publish` (not `npm publish`) — bun rewrites `workspace:*` to the local package version in the published tarball; npm would ship the unresolvable `workspace:*` spec verbatim. `bun publish` authenticates via the `NPM_CONFIG_TOKEN` env var. -- Its `tsconfig.build.json` overrides `paths` to `{}` so openapi-client resolves via its package `exports` (`dist/`) instead of source — otherwise openapi-client's sources would enter the program and violate `rootDir`. The publish job therefore builds openapi-client before building sandbox. +- Sandbox depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-sandbox` job in `release.yml` uses `bun publish` (not `npm publish`) because bun rewrites `workspace:*` to the local package version in the published tarball; npm would ship the unresolvable `workspace:*` spec verbatim. `bun publish` authenticates via the `NPM_CONFIG_TOKEN` env var. +- Its `tsconfig.build.json` overrides `paths` to `{}` so openapi-client resolves via its package `exports` (`dist/`) instead of source. Otherwise openapi-client's sources would enter the program and violate `rootDir`. The publish job therefore builds openapi-client before building sandbox. `@bunny.net/config` is a private workspace package (not published); the CLI consumes it from source via the workspace symlink. @@ -962,7 +962,7 @@ bunny │ └── profile │ ├── create (alias: add) Create a named profile with API key │ └── delete Delete a named profile -├── apps (experimental — hidden from help and landing page) +├── apps (experimental: hidden from help and landing page) │ ├── init [image] [--name] [--dockerfile] [--registry] [--port] [--command] [--config] │ │ Scaffold bunny.jsonc via shared walkthrough (no deploy); --config writes to a specific path │ ├── list (alias: ls) List all apps @@ -1003,7 +1003,7 @@ bunny │ └── remove Remove registry ├── dns Manage DNS zones and records │ │ Two resource groups: `records` (entries in a zone) and `zones` (the zone itself). -│ │ Every [domain] is optional — omit it to use the linked zone (`dns zones link` → .bunny/dns.json), else pick interactively (resolveZoneInteractive; errors instead of prompting under --output json or without a TTY). Picking a zone interactively offers to link the directory (`zones remove` never offers). +│ │ Every [domain] is optional; omit it to use the linked zone (`dns zones link` → .bunny/dns.json), else pick interactively (resolveZoneInteractive; errors instead of prompting under --output json or without a TTY). Picking a zone interactively offers to link the directory (`zones remove` never offers). │ ├── records (canonical; aliases: record, rec) │ │ ├── list [domain] (alias: ls) List the records within a zone │ │ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] @@ -1157,7 +1157,7 @@ bunny ### Overview -API calls use `openapi-fetch` with types generated from OpenAPI specs by `openapi-typescript`. This gives full type safety — paths, params, request bodies, and responses are all inferred from the specs. +API calls use `openapi-fetch` with types generated from OpenAPI specs by `openapi-typescript`. This gives full type safety: paths, params, request bodies, and responses are all inferred from the specs. ### API domains @@ -1207,10 +1207,10 @@ Only type the fields you actually use. When the endpoint is added to the spec, r Prefer generated schema types over inline primitives. When you need a subset of fields from a generated type, use `Pick<>`: ```typescript -// Good — derived from generated schema +// Good: derived from generated schema type Database = Pick; -// Bad — inline primitives that duplicate the schema +// Bad: inline primitives that duplicate the schema type Database = { id: string; name: string; @@ -1269,7 +1269,7 @@ handler: async ({ profile, apiKey, verbose }) => { ## Agent & Scripting Compatibility -The CLI is designed to be fully usable by AI agents, scripts, and pipelines — not just humans. +The CLI is designed to be fully usable by AI agents, scripts, and pipelines, not just humans. ### Non-interactive by default @@ -1278,7 +1278,7 @@ Every command must be runnable without interactive prompts when the right flags - **Every prompt has a flag equivalent.** If a command prompts for input (API key, confirmation, name), there must be a flag that provides the value and skips the prompt entirely. - Confirmation prompts → `--force` flag - Text/password input → named flag (e.g. `--api-key`) -- **Never block on stdin.** If a required value is missing and no prompt flag was given, error immediately — don't hang waiting for input that will never come. +- **Never block on stdin.** If a required value is missing and no prompt flag was given, error immediately instead of hanging on input that will never come. Examples of non-interactive usage: @@ -1320,10 +1320,10 @@ handler: async ({ output, profile, apiKey }) => { return; } - // Tabular data — formatTable handles text, table, csv, markdown + // Tabular data: formatTable handles text, table, csv, markdown logger.log(formatTable(["Name", "Status"], rows, output)); - // Key-value data — formatKeyValue renders as a 2-column table + // Key-value data: formatKeyValue renders as a 2-column table logger.log(formatKeyValue([{ key: "Name", value: "Alice" }], output)); }; ``` @@ -1338,7 +1338,7 @@ Commands that operate on a specific remote resource (e.g. a script, an app) can ### How it works -- **`.bunny/script.json`** (gitignored) — links the current directory to a remote Edge Script. +- **`.bunny/script.json`** (gitignored): links the current directory to a remote Edge Script. - **`.bunny/site.json`** (gitignored): links the current directory to a site (the site's storage zone ID). Written by `bunny sites link`/`create`; the site's own state (resource triple, deploys, current/previous) lives remotely at `_bunny/site.json` inside the storage zone, so the local manifest is only a pointer. - The manifest is machine-managed: written by `bunny scripts link`, read by other script commands. - `resolveManifestId()` in `packages/cli/src/core/manifest.ts` handles the resolution: explicit ID flag → manifest file → error with hint. @@ -1358,9 +1358,9 @@ Commands that operate on a specific remote resource (e.g. a script, an app) can Commands that need a resource ID follow this pattern: -1. **Explicit positional or flag** — `bunny scripts show 12345` or `--script-id 12345` -2. **Manifest file** — `.bunny/script.json` in the current or ancestor directory -3. **Error** — `UserError` with a hint to run `bunny scripts link` +1. **Explicit positional or flag**: `bunny scripts show 12345` or `--script-id 12345` +2. **Manifest file**: `.bunny/script.json` in the current or ancestor directory +3. **Error**: `UserError` with a hint to run `bunny scripts link` ### Adding new resource types @@ -1376,20 +1376,20 @@ The manifest system is generic. To add a new resource type (e.g. containers): **Resolution order:** -1. Explicit positional argument — `bunny db tokens create db_01KCHBG8...` -2. `.bunny/database.json` manifest — written by `bunny db link`, read via `loadManifest(DATABASE_MANIFEST)` -3. `BUNNY_DATABASE_URL` in `.env` — walks up the directory tree, parses the URL, matches it against the database list via API -4. Interactive prompt — fetches all databases and presents a select menu -5. If no databases exist — `UserError` with hint to run `bunny db create` +1. Explicit positional argument: `bunny db tokens create db_01KCHBG8...` +2. `.bunny/database.json` manifest, written by `bunny db link`, read via `loadManifest(DATABASE_MANIFEST)` +3. `BUNNY_DATABASE_URL` in `.env`: walks up the directory tree, parses the URL, matches it against the database list via API +4. Interactive prompt: fetches all databases and presents a select menu +5. If no databases exist, a `UserError` with a hint to run `bunny db create` The URL (e.g. `libsql://...bunnydb.net/`) does not directly contain the `db_id`. The resolver fetches the database list and matches by URL to find the corresponding `db_id`. The manifest stores the `db_id` directly so no list lookup is needed for that path. -The manifest path mirrors `bunny scripts link` — both write to `.bunny/.json` via the same generic `saveManifest()` helper in `packages/cli/src/core/manifest.ts`. +The manifest path mirrors `bunny scripts link`: both write to `.bunny/.json` via the same generic `saveManifest()` helper in `packages/cli/src/core/manifest.ts`. **Lifecycle integration:** -- `bunny db create` — the name is validated client-side against `DB_NAME_MAX_LENGTH` (16) before any API call, since longer names make the backend 500 instead of returning a validation error. After creating the database, prompts "Link this directory to ?" and (on yes) writes the manifest. If a link already exists it shows what will be replaced. The follow-up flow (link → token → save-env) exposes three flags for non-interactive control: `--link`/`--no-link`, `--token`/`--no-token`, `--save-env`/`--no-save-env`. When a flag is provided the prompt is skipped; in `--output json` mode prompts are suppressed entirely so flags become the only way to opt in. The JSON output then includes `linked`, `token`, and `saved_to_env` fields reflecting what happened. -- `bunny db delete` — after deleting the database, if `.bunny/database.json` points at the deleted ID it is removed silently via `removeManifest()` (no prompt — a manifest pointing at a deleted DB is unambiguously stale). +- `bunny db create`: the name is validated client-side against `DB_NAME_MAX_LENGTH` (16) before any API call, since longer names make the backend 500 instead of returning a validation error. After creating the database, prompts "Link this directory to ?" and (on yes) writes the manifest. If a link already exists it shows what will be replaced. The follow-up flow (link → token → save-env) exposes three flags for non-interactive control: `--link`/`--no-link`, `--token`/`--no-token`, `--save-env`/`--no-save-env`. When a flag is provided the prompt is skipped; in `--output json` mode prompts are suppressed entirely so flags become the only way to opt in. The JSON output then includes `linked`, `token`, and `saved_to_env` fields reflecting what happened. +- `bunny db delete`: after deleting the database, if `.bunny/database.json` points at the deleted ID it is removed silently via `removeManifest()` (no prompt, since a manifest pointing at a deleted DB is unambiguously stale). ### `bunny.jsonc` (app config) @@ -1418,7 +1418,7 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: } ``` -`version` is an ISO date string. The apps flow requires it on load — if a config is missing `version`, `loadConfig` throws a `UserError` with a hint to regenerate via `bunny apps pull`. (The sites flow is lenient: a sites-only file needs neither `version` nor an `app` block.) There is no migration runner yet; when the first breaking shape change ships, that PR introduces one alongside its transform. +`version` is an ISO date string. The apps flow requires it on load: if a config is missing `version`, `loadConfig` throws a `UserError` with a hint to regenerate via `bunny apps pull`. (The sites flow is lenient: a sites-only file needs neither `version` nor an `app` block.) There is no migration runner yet; when the first breaking shape change ships, that PR introduces one alongside its transform. Schemas and types are defined in `@bunny.net/config` using Zod. `core/bunny-config.ts` owns `bunny.jsonc` discovery + raw read (shared by the apps and sites flows). The apps `config.ts` layers validation, resolution helpers (`resolveAppId`, `resolveContainerId`), and writes: a new file is serialized fresh with `$schema` + `version` first, while an existing file is edited surgically via `core/jsonc.ts` (`syncJsonc`) so comments, key order, and a sibling `sites` block survive. @@ -1446,19 +1446,19 @@ The database shell is an interactive SQL REPL that connects to a Bunny Database The shell is split across two packages: -- **`@bunny.net/database-shell`** (`packages/database-shell/`) — Framework-agnostic shell engine. Contains the REPL, dot-commands, result formatting, masking, history, and SQL parsing. Accepts a `@libsql/client` `Client` instance and an optional `ShellLogger` interface for output. -- **`@bunny.net/cli`** (`packages/cli/src/commands/db/shell.ts`) — Thin CLI wrapper. Handles credential resolution (API client, `.env` lookup, interactive prompts), yargs command definition, and delegates to the shell package. +- **`@bunny.net/database-shell`** (`packages/database-shell/`): framework-agnostic shell engine. Contains the REPL, dot-commands, result formatting, masking, history, and SQL parsing. Accepts a `@libsql/client` `Client` instance and an optional `ShellLogger` interface for output. +- **`@bunny.net/cli`** (`packages/cli/src/commands/db/shell.ts`): thin CLI wrapper. Handles credential resolution (API client, `.env` lookup, interactive prompts), yargs command definition, and delegates to the shell package. **Shell engine components** (in `packages/database-shell/src/`): -- **REPL** (`shell.ts`) — `startShell()`, `executeQuery()`, `executeFile()`. Uses `node:readline` with multi-line SQL support. -- **Dot-commands** (`dot-commands.ts`) — `.tables`, `.schema`, `.describe`, `.indexes`, `.fk`, `.er`, `.count`, `.size`, `.truncate`, `.dump`, `.read`, `.mode`, `.timing`, `.mask`, `.unmask`, `.save`, `.view`, `.views`, `.unsave`, `.clear-history`, `.help`, `.quit`. -- **Formatting** (`format.ts`) — `printResultSet()` with 5 output modes: `default`, `table`, `json`, `csv`, `markdown`. Sensitive column masking (full mask for passwords/secrets, email mask for email columns). -- **Views** (`views.ts`) — Saved queries scoped per database. Stored at `~/.config/bunny/views//` (respects `XDG_CONFIG_HOME`). Callers can override via `ShellOptions.viewsDir`. -- **History** (`history.ts`) — Stored at `~/.config/bunny/shell_history` (respects `XDG_CONFIG_HOME`). Max 1000 entries. -- **SQL parsing** (`parser.ts`) — `splitStatements()` for `.sql` file execution. Splits on `;` outside single-quoted strings and SQLite's double-quote/backtick/bracket identifier forms, strips line and block comments (so drizzle's `--> statement-breakpoint` markers are ignored), keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact, and rejects unterminated quotes/comments rather than returning truncated SQL. +- **REPL** (`shell.ts`): `startShell()`, `executeQuery()`, `executeFile()`. Uses `node:readline` with multi-line SQL support. +- **Dot-commands** (`dot-commands.ts`): `.tables`, `.schema`, `.describe`, `.indexes`, `.fk`, `.er`, `.count`, `.size`, `.truncate`, `.dump`, `.read`, `.mode`, `.timing`, `.mask`, `.unmask`, `.save`, `.view`, `.views`, `.unsave`, `.clear-history`, `.help`, `.quit`. +- **Formatting** (`format.ts`): `printResultSet()` with 5 output modes: `default`, `table`, `json`, `csv`, `markdown`. Sensitive column masking (full mask for passwords/secrets, email mask for email columns). +- **Views** (`views.ts`): saved queries scoped per database. Stored at `~/.config/bunny/views//` (respects `XDG_CONFIG_HOME`). Callers can override via `ShellOptions.viewsDir`. +- **History** (`history.ts`): stored at `~/.config/bunny/shell_history` (respects `XDG_CONFIG_HOME`). Max 1000 entries. +- **SQL parsing** (`parser.ts`): `splitStatements()` for `.sql` file execution. Splits on `;` outside single-quoted strings and SQLite's double-quote/backtick/bracket identifier forms, strips line and block comments (so drizzle's `--> statement-breakpoint` markers are ignored), keeps `CREATE TRIGGER ... BEGIN ... END;` bodies intact, and rejects unterminated quotes/comments rather than returning truncated SQL. -**Dependency injection** — The shell engine accepts a `ShellLogger` interface instead of importing the CLI logger directly: +**Dependency injection**: the shell engine accepts a `ShellLogger` interface instead of importing the CLI logger directly: ```typescript interface ShellLogger { @@ -1494,7 +1494,7 @@ Dot-commands that perform full table scans (`.count`, `.size`, `.dump`) warn the SQL can be passed as a positional argument or via `--execute`/`-e`. Smart detection: if the first positional doesn't start with `db_`, it's treated as the query rather than a database ID. -If the value ends with `.sql` and the file exists, statements are read from the file instead — split on `;` and executed sequentially. Execution stops on the first error. +If the value ends with `.sql` and the file exists, statements are read from the file instead, split on `;` and executed sequentially. Execution stops on the first error. ```bash bunny db shell "SELECT * FROM users" @@ -1523,13 +1523,13 @@ Schema changes live in plain `.sql` files that the developer writes (or generate All file and state logic is here so the commands stay thin and the logic is testable against an in-memory libSQL database (`engine.test.ts`, no network): -- `resolveMigrationsDir(dirArg?)` — `--dir` wins; otherwise `migrations/`, falling back to `drizzle/` when `migrations/` doesn't exist (`detected: true` so the caller can say which directory it used). `resolveCreateMigrationsDir()` deliberately skips fallback detection so `create` never writes an unjournaled file into an ORM directory. -- `discoverMigrations(dir, pattern)` — every `.sql` file matched by a positive `Bun.Glob` relative to `dir`, sorted by portable slash-separated relative path. The default `*.sql` stays top-level; `*/migration.sql` and `**/*.sql` opt into nested layouts. Absolute/traversing/negated patterns are rejected. -- `checksum(sql)` — sha256 of the body with CRLF normalized and edges trimmed, so reformatting line endings isn't reported as a change. -- `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)` — join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`; an unseen file that sorts before the newest applied path is `out_of_order`. -- `migrationStatements(file)` — parses one file and converts lexical failures into a hinted `UserError`. `apply` calls it for every pending migration before the first database write, so a malformed later file cannot cause a predictably partial run. -- `applyMigration(client, file, options)` — runs the prepared statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. -- `readApplied(client)` — the read path for `list` and for `apply` before confirmation. Checks `sqlite_master` rather than creating the tracking table, so a preview never writes, and converts connection or query failures into a hinted `UserError` instead of an unexpected-error exit. +- `resolveMigrationsDir(dirArg?)`: `--dir` wins; otherwise `migrations/`, falling back to `drizzle/` when `migrations/` doesn't exist (`detected: true` so the caller can say which directory it used). `resolveCreateMigrationsDir()` deliberately skips fallback detection so `create` never writes an unjournaled file into an ORM directory. +- `discoverMigrations(dir, pattern)`: every `.sql` file matched by a positive `Bun.Glob` relative to `dir`, sorted by portable slash-separated relative path. The default `*.sql` stays top-level; `*/migration.sql` and `**/*.sql` opt into nested layouts. Absolute/traversing/negated patterns are rejected. +- `checksum(sql)`: sha256 of the body with CRLF normalized and edges trimmed, so reformatting line endings isn't reported as a change. +- `migrationStatuses(files, applied)` / `pendingMigrations(files, applied)`: join disk against the table. A recorded migration whose file changed is `modified`; one whose file is gone is `missing`; an unseen file that sorts before the newest applied path is `out_of_order`. +- `migrationStatements(file)`: parses one file and converts lexical failures into a hinted `UserError`. `apply` calls it for every pending migration before the first database write, so a malformed later file cannot cause a predictably partial run. +- `applyMigration(client, file, options)`: runs the prepared statements plus the tracking-row insert through `client.migrate()`, so a migration either lands and is recorded or neither happens. +- `readApplied(client)`: the read path for `list` and for `apply` before confirmation. Checks `sqlite_master` rather than creating the tracking table, so a preview never writes, and converts connection or query failures into a hinted `UserError` instead of an unexpected-error exit. `client.migrate()` is used rather than `client.batch()` because it defers foreign key enforcement for the batch, which table rebuilds and `ALTER TABLE` need. `db shell .sql` still uses `batch()` and is not migration-aware. @@ -1621,7 +1621,7 @@ Before plugins can ship, the CLI core utilities need to be extracted into a shar ### Design principles -- **Keep `defineCommand` and `defineNamespace` interfaces clean and stable** — they will become the public plugin API. -- **Built-in over plugin for core bunny.net primitives** — analytics, streaming, storage sync, DNS, and logs should be first-class commands, not plugins. +- **Keep `defineCommand` and `defineNamespace` interfaces clean and stable**: they will become the public plugin API. +- **Built-in over plugin for core bunny.net primitives**: analytics, streaming, storage sync, DNS, and logs should be first-class commands, not plugins. - **Plugins are best for**: framework-specific adapters (Next.js, Laravel, WordPress), third-party integrations (Datadog, Slack, PagerDuty), and organization-specific workflows. -- **Unix composability first** — built-in commands should output to stdout in structured formats (`--output json`) so users can pipe to any tool. Plugins add value with pre-built integrations on top. +- **Unix composability first**: built-in commands should output to stdout in structured formats (`--output json`) so users can pipe to any tool. Plugins add value with pre-built integrations on top. diff --git a/README.md b/README.md index 9467322c..2a7299b2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ bun ny sites ci init # add a GitHub Actions workflow (pre Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). -### Available Scripts +### Available scripts ```bash # Type check the entire monorepo From e233fede2b48bfc2a2465f5b6b5dd1c1d61da13a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 13 Aug 2026 14:47:48 +0100 Subject: [PATCH 08/10] Enforce encrypted database URLs and endpoint matching --- .changeset/db-migrations.md | 2 +- AGENTS.md | 7 +- .../cli/src/commands/db/credentials.test.ts | 105 +++++++++++++++--- packages/cli/src/commands/db/credentials.ts | 57 ++++++---- skills/bunny-cli/references/database.md | 8 +- 5 files changed, 133 insertions(+), 46 deletions(-) diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md index e9aba964..a77b7879 100644 --- a/.changeset/db-migrations.md +++ b/.changeset/db-migrations.md @@ -3,4 +3,4 @@ "@bunny.net/database-shell": patch --- -feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `--pattern` supports nested ORM layouts while checksum drift and out-of-order files block unsafe applies unless `--allow-drift` is explicit; migration commands show the credential-free database target; `splitStatements` keeps `CREATE TRIGGER` bodies intact, supports every SQLite quote form, drops comments, and rejects truncated SQL; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials and refuse to send a token to a `--url` on a different host or over an unencrypted connection +feat(db): `bunny db migrations create/list/apply` runs numbered `.sql` files in `migrations/` (or `drizzle/`) once each, tracked in `__bunny_migrations`; `--pattern` supports nested ORM layouts while checksum drift and out-of-order files block unsafe applies unless `--allow-drift` is explicit; migration commands show the credential-free database target; `splitStatements` keeps `CREATE TRIGGER` bodies intact, supports every SQLite quote form, drops comments, and rejects truncated SQL; `db shell`, `db studio`, and `db migrations apply` now honour an explicit database ID over `.env` credentials, require encrypted hosted database URLs regardless of token source, and refuse to send an ambient or generated token to a different hostname or service port diff --git a/AGENTS.md b/AGENTS.md index 61411ac0..e89532f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1475,10 +1475,9 @@ interface ShellLogger { - Credential resolution via `resolveCredentials()` in `packages/cli/src/commands/db/credentials.ts` (--url/--token flags → .env → API lookup), shared with `db studio` and `db migrations apply`. Its job is to never pair a credential with a target the user didn't pair it with: - An explicit database ID skips `.env` entirely. `.env` may describe a different database, and silently connecting there would target the wrong one. -- A generated token is only sent to a URL whose host matches that database's canonical URL, so `--url` without `--token` is rejected on mismatch. The host check runs before the token is created, so nothing is minted for an endpoint we'd refuse. -- The `.env` token is only reused for an explicit `--url` on the same host as the `.env` URL (`envTokenAllowedFor()`). That pairing is the user's own and holds for nothing else, so an override addressing anywhere else falls through to the API path, where a fresh token is created and checked against the canonical URL. The comparison is against `.env` rather than the API so the offline case (both values in `.env`, `--url` naming the same host) still needs no network call. -- A token bound for an explicit `--url` must travel encrypted. `isEncrypted()` allows `libsql:`, `https:`, and `wss:`, and rejects `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. The scheme check runs first of all, before any lookup or prompt, so an unusable URL fails immediately instead of after a database prompt. -- An explicit `--token` is exempt from all of the above: pairing it with `--url` is deliberate, and it covers a local `sqld` over plain http. +- A generated token is only sent to a URL whose endpoint matches that database's canonical URL, so `--url` without `--token` is rejected on a hostname or normalized-port mismatch. The endpoint check runs before the token is created, so nothing is minted for an endpoint we'd refuse. +- The `.env` token is only reused for an explicit `--url` on the same endpoint as the `.env` URL (`envTokenAllowedFor()`). Endpoint identity includes the hostname and normalized TLS port, while allowing equivalent `libsql:`, `https:`, and `wss:` schemes. An override addressing anywhere else falls through to the API path, where a fresh token is created and checked against the canonical URL. The comparison is against `.env` rather than the API so the offline case (both values in `.env`, `--url` naming the same endpoint) still needs no network call. +- Every database URL must be encrypted, including URLs paired with an explicit `--token`. `isEncrypted()` allows `libsql:`, `https:`, and `wss:`, and rejects `libsql://host:port?tls=0`, which the libSQL client downgrades to plaintext. The scheme check runs before any lookup or prompt so an unusable URL fails immediately instead of after a database prompt. This CLI targets hosted Bunny Database and does not support a plaintext local-database exception. The invariant behind all of it: a credential the user didn't pass on this command line is never sent to a target they did. diff --git a/packages/cli/src/commands/db/credentials.test.ts b/packages/cli/src/commands/db/credentials.test.ts index f018e8c2..b20f4930 100644 --- a/packages/cli/src/commands/db/credentials.test.ts +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { databaseTarget, envTokenAllowedFor, isEncrypted, - sameHost, + resolveCredentials, + sameEndpoint, } from "./credentials.ts"; const CANONICAL = "libsql://my-db-abc.lite.bunnydb.net/"; @@ -34,10 +38,10 @@ describe("databaseTarget", () => { describe("envTokenAllowedFor", () => { test("allows the .env token when no --url overrides it", () => { expect(envTokenAllowedFor(undefined, CANONICAL)).toBe(true); - expect(envTokenAllowedFor(undefined, undefined)).toBe(true); + expect(envTokenAllowedFor(undefined, undefined)).toBe(false); }); - test("allows a --url naming the same host as the .env URL", () => { + test("allows a --url naming the same endpoint as the .env URL", () => { expect( envTokenAllowedFor("libsql://my-db-abc.lite.bunnydb.net", CANONICAL), ).toBe(true); @@ -55,6 +59,12 @@ describe("envTokenAllowedFor", () => { ).toBe(false); }); + test("refuses an encrypted --url on a different port", () => { + expect( + envTokenAllowedFor("libsql://my-db-abc.lite.bunnydb.net:8443", CANONICAL), + ).toBe(false); + }); + test("refuses a plaintext --url even on the matching host", () => { expect( envTokenAllowedFor("http://my-db-abc.lite.bunnydb.net", CANONICAL), @@ -100,44 +110,107 @@ describe("isEncrypted", () => { }); }); -describe("sameHost", () => { +describe("sameEndpoint", () => { test("accepts the canonical URL with or without a trailing slash", () => { - expect(sameHost("libsql://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( + expect(sameEndpoint("libsql://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( true, ); - expect(sameHost(CANONICAL, CANONICAL)).toBe(true); + expect(sameEndpoint(CANONICAL, CANONICAL)).toBe(true); }); - test("accepts https for the same host, since libsql maps onto it", () => { - expect(sameHost("https://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( + test("accepts https for the same endpoint, since libsql maps onto it", () => { + expect(sameEndpoint("https://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( true, ); }); + test("normalizes an explicit default TLS port", () => { + expect( + sameEndpoint("libsql://my-db-abc.lite.bunnydb.net:443", CANONICAL), + ).toBe(true); + }); + + test("rejects an alternate service port", () => { + expect( + sameEndpoint("libsql://my-db-abc.lite.bunnydb.net:8443", CANONICAL), + ).toBe(false); + }); + test("ignores host casing and path", () => { expect( - sameHost("libsql://MY-DB-ABC.lite.bunnydb.net/anything", CANONICAL), + sameEndpoint("libsql://MY-DB-ABC.lite.bunnydb.net/anything", CANONICAL), ).toBe(true); }); test("rejects a different database on the same domain", () => { - expect(sameHost("libsql://other-db-xyz.lite.bunnydb.net", CANONICAL)).toBe( - false, - ); + expect( + sameEndpoint("libsql://other-db-xyz.lite.bunnydb.net", CANONICAL), + ).toBe(false); }); test("rejects a foreign host", () => { - expect(sameHost("libsql://evil.example.com", CANONICAL)).toBe(false); + expect(sameEndpoint("libsql://evil.example.com", CANONICAL)).toBe(false); }); test("rejects a host that only prefixes the canonical one", () => { expect( - sameHost("libsql://my-db-abc.lite.bunnydb.net.example.com", CANONICAL), + sameEndpoint( + "libsql://my-db-abc.lite.bunnydb.net.example.com", + CANONICAL, + ), ).toBe(false); }); test("rejects unparseable input rather than treating it as a match", () => { - expect(sameHost("my-db-abc.lite.bunnydb.net", CANONICAL)).toBe(false); - expect(sameHost("", CANONICAL)).toBe(false); + expect(sameEndpoint("my-db-abc.lite.bunnydb.net", CANONICAL)).toBe(false); + expect(sameEndpoint("", CANONICAL)).toBe(false); + }); +}); + +describe("resolveCredentials", () => { + test("rejects a plaintext explicit URL even with an explicit token", async () => { + await expect( + resolveCredentials({ + profile: "default", + url: "http://my-db-abc.lite.bunnydb.net", + token: "explicit-token", + }), + ).rejects.toThrow("Database URL must use an encrypted connection."); + }); + + test("returns an encrypted explicit URL and token without an API lookup", async () => { + await expect( + resolveCredentials({ + profile: "default", + url: CANONICAL, + token: "explicit-token", + }), + ).resolves.toEqual({ + url: CANONICAL, + token: "explicit-token", + databaseId: undefined, + tokenGenerated: false, + }); + }); + + test("rejects a plaintext .env URL before returning its ambient token", async () => { + const cwd = process.cwd(); + const dir = mkdtempSync(join(tmpdir(), "bunny-db-credentials-")); + writeFileSync( + join(dir, ".env"), + [ + "BUNNY_DATABASE_URL=http://my-db-abc.lite.bunnydb.net", + "BUNNY_DATABASE_AUTH_TOKEN=ambient-token", + ].join("\n"), + ); + process.chdir(dir); + + try { + await expect(resolveCredentials({ profile: "default" })).rejects.toThrow( + "Database URL must use an encrypted connection.", + ); + } finally { + process.chdir(cwd); + } }); }); diff --git a/packages/cli/src/commands/db/credentials.ts b/packages/cli/src/commands/db/credentials.ts index 9fe4834d..b79f0b1d 100644 --- a/packages/cli/src/commands/db/credentials.ts +++ b/packages/cli/src/commands/db/credentials.ts @@ -33,6 +33,7 @@ export interface ResolveCredentialsOptions { /** Schemes that encrypt in transit. `libsql:` resolves to `https:`/`wss:` unless it opts out with `?tls=0`. */ const ENCRYPTED_SCHEMES = new Set(["libsql:", "https:", "wss:"]); +const DEFAULT_TLS_PORT = "443"; /** A credential-free database identity suitable for prompts and structured output. */ export function databaseTarget( @@ -69,35 +70,54 @@ export function isEncrypted(url: string): boolean { } } -/** Same host, ignoring scheme, port, and path, since `libsql://` and `https://` address the same endpoint. */ -export function sameHost(a: string, b: string): boolean { +/** + * Same encrypted service endpoint, allowing equivalent libSQL/HTTP/WebSocket + * schemes but not a different authority port. + */ +export function sameEndpoint(a: string, b: string): boolean { try { + const first = new URL(a); + const second = new URL(b); + const firstPort = first.port || DEFAULT_TLS_PORT; + const secondPort = second.port || DEFAULT_TLS_PORT; + return ( - new URL(a).hostname.toLowerCase() === new URL(b).hostname.toLowerCase() + first.hostname.toLowerCase() === second.hostname.toLowerCase() && + firstPort === secondPort ); } catch { return false; } } +function requireEncrypted(url: string): void { + if (isEncrypted(url)) return; + + throw new UserError( + "Database URL must use an encrypted connection.", + "Use the libsql://, https://, or wss:// URL provided by Bunny Database.", + ); +} + /** * True when the token stored in `.env` may be sent to an explicit `--url`. * * The `.env` token belongs to the `.env` URL: that pairing is the user's own, so - * it holds for the same host and nothing else. An override addressing anywhere + * it holds for the same endpoint and nothing else. An override addressing anywhere * else falls through to the API path, where a fresh token is created and checked * against the database's canonical URL instead of reusing the stored one. * * Checked against `.env` rather than the API so the offline case (both values in - * `.env`, `--url` naming the same host) still needs no network call. + * `.env`, `--url` naming the same endpoint) still needs no network call. */ export function envTokenAllowedFor( explicitUrl: string | undefined, envUrl: string | undefined, ): boolean { - if (!explicitUrl) return true; if (!envUrl) return false; - return sameHost(explicitUrl, envUrl) && isEncrypted(explicitUrl); + if (!isEncrypted(envUrl)) return false; + if (!explicitUrl) return true; + return sameEndpoint(explicitUrl, envUrl) && isEncrypted(explicitUrl); } /** @@ -114,9 +134,8 @@ export function envTokenAllowedFor( * The rule for tokens is that a credential the user didn't pass on this command * line is never sent to a target they did. So a generated token only goes to an * encrypted URL belonging to the database it was created for, and the `.env` - * token only goes to an encrypted `--url` on the same host as the `.env` URL. - * A token passed as `--token` is left alone: pairing it with `--url` is explicit, - * and it covers connecting to a local `sqld` over plain http. + * token only goes to an encrypted `--url` on the same endpoint as the `.env` + * URL. Every URL must be encrypted, including URLs paired with an explicit token. * * Shared by `db shell`, `db studio`, and `db migrations apply`. */ @@ -133,6 +152,8 @@ export async function resolveCredentials( let token = opts.token ?? (envTokenAllowedFor(opts.url, envUrl) ? envToken : undefined); + if (url) requireEncrypted(url); + if (url && token) { return { url, @@ -142,14 +163,6 @@ export async function resolveCredentials( }; } - // Refuse a plaintext target up front, before any lookup, prompt, or token creation. - if (opts.url && !token && !isEncrypted(opts.url)) { - throw new UserError( - "--url must be encrypted to receive a generated token.", - "Use libsql:// or https://, or pass --token to send your own credential.", - ); - } - const config = resolveConfig(opts.profile, opts.apiKey, opts.verbose); const apiClient = createDbClient(clientOptions(config, opts.verbose)); @@ -175,7 +188,7 @@ export async function resolveCredentials( try { if (url && willGenerateToken) { - // Verify the override before creating a token, so a token is never created for a host we'd refuse. + // Verify the override before creating a token, so a token is never created for an endpoint we'd refuse. const { data } = await fetchDatabase(); const canonical = data?.db?.url; @@ -183,10 +196,10 @@ export async function resolveCredentials( throw new UserError(`Could not fetch database ${databaseId}.`); } - if (!sameHost(url, canonical)) { + if (!sameEndpoint(url, canonical)) { throw new UserError( `--url does not point at ${databaseId}.`, - `Pass --token for that URL, or drop --url to connect to ${canonical}.`, + `Use the URL provided by Bunny Database, or drop --url to connect to ${canonical}.`, ); } @@ -208,5 +221,7 @@ export async function resolveCredentials( throw new UserError("Could not resolve database URL or generate token."); } + requireEncrypted(url); + return { url, token, databaseId, tokenGenerated: willGenerateToken }; } diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index d204db8a..d8a05544 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -177,13 +177,13 @@ bunny db shell --url libsql://... --token ey... # explicit credentials 2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` 3. API lookup (fetches URL and generates a temporary token) -Shared by `db shell`, `db studio`, and `db migrations apply`. Two rules apply: +Shared by `db shell`, `db studio`, and `db migrations apply`. These rules apply: - **Passing a database ID skips `.env`.** `bunny db shell db_01ABC` targets that database even when `.env` describes another one, so an explicit target is never silently redirected. -- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own host; a mismatch errors and asks for `--token`. The token from `.env` is likewise only reused for a `--url` on the same host as `BUNNY_DATABASE_URL`. -- **`--url` must be encrypted to receive a token you didn't pass yourself.** `libsql://`, `https://`, and `wss://` are accepted; `http://`, `ws://`, and `libsql://host:port?tls=0` are refused. +- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own endpoint (hostname and normalized TLS port); a mismatch directs the user back to the Bunny-provided URL. The token from `.env` is likewise only reused for a `--url` matching the endpoint in `BUNNY_DATABASE_URL`. +- **Every database URL must be encrypted.** `libsql:`, `https:`, and `wss:` are accepted, while plaintext schemes and `libsql:` URLs with `tls=0` are rejected regardless of where the token came from. -In short, a credential you didn't type on the command line never goes to a URL you did. Passing `--token` alongside a plaintext or foreign `--url` is always allowed, for cases like a local `sqld`. +The CLI targets hosted Bunny Database connections and does not provide a plaintext local-database exception. ### REPL dot-commands From 074d492d2b5a609b835b0a1a4b5b72acd3120ec3 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 13 Aug 2026 14:59:53 +0100 Subject: [PATCH 09/10] Preserve CREATE TRIGGER IF NOT EXISTS statements --- packages/database-shell/src/shell.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/database-shell/src/shell.test.ts b/packages/database-shell/src/shell.test.ts index 3ab51d8a..d8dc6143 100644 --- a/packages/database-shell/src/shell.test.ts +++ b/packages/database-shell/src/shell.test.ts @@ -600,6 +600,12 @@ describe("splitStatements", () => { ]); }); + test("keeps CREATE TRIGGER IF NOT EXISTS intact", () => { + const sql = + "CREATE TRIGGER IF NOT EXISTS touch AFTER UPDATE ON users BEGIN\n UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;\nEND;"; + expect(splitStatements(sql)).toEqual([sql.slice(0, -1)]); + }); + test("keeps a multi-statement trigger body intact and splits what follows", () => { const sql = "CREATE TEMPORARY TRIGGER log AFTER INSERT ON t BEGIN\n INSERT INTO audit VALUES (1);\n INSERT INTO audit VALUES (2);\nEND;\nSELECT 1;"; From 01b1c916037898b64721158b082f91d9cdfa64a8 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 20 Aug 2026 10:17:09 +0100 Subject: [PATCH 10/10] Mark db migrations as experimental in help --- AGENTS.md | 2 +- packages/cli/src/commands/db/migrations/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cd51551..be3193e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1097,7 +1097,7 @@ bunny │ ├── docs Open database documentation in browser │ ├── list (alias: ls) [--group-id] │ │ List all databases -│ ├── migrations Create and apply SQL migrations (files are the source of truth) +│ ├── migrations (experimental) Create and apply SQL migrations (files are the source of truth) │ │ ├── apply [database-id] [--dir] [--pattern] [--url] [--token] [--dry-run] [--force] [--allow-drift] │ │ │ Apply pending migrations in filename order (each file + its tracking row is one atomic batch) │ │ ├── create [name] (alias: new) [--dir] diff --git a/packages/cli/src/commands/db/migrations/index.ts b/packages/cli/src/commands/db/migrations/index.ts index 621fb8df..8350ce76 100644 --- a/packages/cli/src/commands/db/migrations/index.ts +++ b/packages/cli/src/commands/db/migrations/index.ts @@ -5,7 +5,7 @@ import { dbMigrationsListCommand } from "./list.ts"; export const dbMigrationsNamespace = defineNamespace( "migrations", - "Create and apply SQL migrations.", + "Create and apply SQL migrations. (experimental)", [ dbMigrationsApplyCommand, dbMigrationsCreateCommand,