diff --git a/.changeset/db-migrations.md b/.changeset/db-migrations.md new file mode 100644 index 00000000..a77b7879 --- /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`; `--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/.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 a74b2f49..be3193e3 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`/`stat`) 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`/`stat`) 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,30 +86,30 @@ 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, authMiddleware, errors, ClientOptions type, DNS scan type corrections │ │ ├── core.ts … stream.ts # Per-API subpath entrypoints (core, compute, database, magic-containers, origin-errors, shield, storage, stream): each re-exports its client factory + generated spec types (backs the package.json "./" exports) -│ │ ├── 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 @@ -176,7 +176,7 @@ bunny-cli/ │ ├── core/ │ │ ├── agent-skill.ts # Generic project skill installer/remover: marked AGENTS.md block upsert/remove + skill file writes (Claude-gated for projects; ~/.agents/skills + ~/.claude/skills for --global); project writes refuse symlink escapes; SKILL.md is a completion sentinel (removed first, written last per root) and the installed check requires every global root, so partial installs and failed refreshes re-offer │ │ ├── agent-skill.test.ts # Tests for install/upsert idempotency, marker scoping, Claude gating -│ │ ├── 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 @@ -216,9 +216,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) @@ -270,21 +270,22 @@ bunny-cli/ │ │ │ ├── login.ts # Browser-based login via Bun.serve() callback, with headless detection and an API key fallback (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, account id, 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) │ │ │ ├── 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 @@ -295,23 +296,31 @@ 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/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 +│ │ │ │ ├── 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 @@ -326,7 +335,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 +425,7 @@ bunny-cli/ │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous; each pruned deploy's preview zone is deleted first, discovered via findPreviewZones for records whose zone create raced a failed state write; a failed zone deletion keeps the record and files so the next prune retries) + prune.test.ts, delete [id] (single-deploy cleanup for CI, e.g. a closed PR's preview: deleteBlocker refuses current/previous with --force only skipping the confirmation, revalidated on freshly re-read state inside the destructive phase since PR cleanup can race the production publish of the same sha; an already-gone id is a no-op success so re-runs converge; a failed zone listing always aborts because a forgotten record would never be retried, unlike prune) + delete.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: the first added domain is recorded as state.domain (display-only production URL; previews run on their own b-cdn.net zones and never depend on it; recordSiteDomain rolls back the in-memory value if the state write fails), and an add on a site with nothing published hints at `deploy --production` (the domain serves the router's 404 until then); remove clears state.domain. setupSiteDomain (create --domain + deploy's first-run offer) records the domain only once the hostname is verifiably on the zone │ │ ├── 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 @@ -431,7 +440,7 @@ bunny-cli/ │ │ │ ├── offer.ts # One-time global-install nudge: interactive offer after bunny login + passive post-command stderr hint for users who never log in (shared marker in the XDG cache dir) │ │ │ └── remove.ts # bunny skills remove [--global] [--force]: strips the AGENTS.md block and deletes the skill dirs for either scope │ │ └── 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) @@ -458,7 +467,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) @@ -491,20 +500,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 the per-API entrypoints (`@bunny.net/openapi-client/`, e.g. `@bunny.net/openapi-client/core`); the older `@bunny.net/openapi-client/generated/.d.ts` paths remain supported. - **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). Tool-config output (`--format rclone|aws|...`) is the exception because it exists to be consumed by tools: it always emits full values. A prompt or flag whose whole purpose is to hand over credentials counts as explicitly asking, and prints in full with a "treat like a password" warning (`storage zones add --connection http|ftp|s3`, and its interactive "Show connection details?" prompt); masking there would leave the user with nothing usable and a second command to run. `storage zones credentials` keeps masking by default because there the credential is the whole command and may be run casually. `--output json` masks like the table; `--show-secret` reveals there too. Never print a secret the user did not explicitly ask to see. Reference: `storage zones credentials` masks the S3 secret access key by default in both the table and JSON 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. @@ -593,11 +602,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. --- @@ -654,12 +663,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 `""`. @@ -732,10 +741,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. --- @@ -765,9 +774,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 @@ -835,16 +844,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. --- @@ -947,19 +956,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` + `LICENSE`). 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` + `LICENSE`). The package versions independently of the CLI; it is not part of any `fixed` group in `.changeset/config.json`. ### Publishing `@bunny.net/sandbox` @@ -967,8 +976,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. @@ -991,7 +1000,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 @@ -1032,7 +1041,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] @@ -1088,6 +1097,13 @@ bunny │ ├── docs Open database documentation in browser │ ├── list (alias: ls) [--group-id] │ │ List all databases +│ ├── 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] +│ │ │ Write an empty migrations/NNNN_.sql (prompts for name when omitted) +│ │ └── 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 @@ -1182,7 +1198,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 @@ -1232,10 +1248,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; @@ -1301,7 +1317,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 @@ -1310,7 +1326,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: @@ -1352,10 +1368,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)); }; ``` @@ -1382,7 +1398,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. @@ -1402,9 +1418,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 @@ -1420,20 +1436,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) @@ -1462,7 +1478,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. @@ -1490,19 +1506,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. +- **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 { @@ -1516,7 +1532,15 @@ 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`. 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 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. + - `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 @@ -1529,7 +1553,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" @@ -1541,6 +1565,65 @@ 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 **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). `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. + +### ORM-generated migrations + +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 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 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. + +```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/`). @@ -1597,7 +1680,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 72e97674..dac57484 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ bun ny # Examples bun ny login # offers to install the agent skill after authenticating; --install-skill/--no-install-skill decides without prompting 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 / 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 skills install # install the bunny agent skill into this project (AGENTS.md block + .claude/skills when Claude Code is used) so AI coding tools know how to use the CLI; alias: skills update bun ny skills install --global # install to ~/.agents/skills and ~/.claude/skills for every project bun ny skills remove # remove the skill from this project (or --global); everything is regenerable with skills install @@ -76,7 +80,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`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. 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 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..b20f4930 --- /dev/null +++ b/packages/cli/src/commands/db/credentials.test.ts @@ -0,0 +1,216 @@ +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, + resolveCredentials, + sameEndpoint, +} 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); + expect(envTokenAllowedFor(undefined, undefined)).toBe(false); + }); + + test("allows a --url naming the same endpoint 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 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), + ).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); + 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("sameEndpoint", () => { + test("accepts the canonical URL with or without a trailing slash", () => { + expect(sameEndpoint("libsql://my-db-abc.lite.bunnydb.net", CANONICAL)).toBe( + true, + ); + expect(sameEndpoint(CANONICAL, CANONICAL)).toBe(true); + }); + + 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( + sameEndpoint("libsql://MY-DB-ABC.lite.bunnydb.net/anything", CANONICAL), + ).toBe(true); + }); + + test("rejects a different database on the same domain", () => { + expect( + sameEndpoint("libsql://other-db-xyz.lite.bunnydb.net", CANONICAL), + ).toBe(false); + }); + + test("rejects a foreign host", () => { + expect(sameEndpoint("libsql://evil.example.com", CANONICAL)).toBe(false); + }); + + test("rejects a host that only prefixes the canonical one", () => { + expect( + 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(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 new file mode 100644 index 00000000..b79f0b1d --- /dev/null +++ b/packages/cli/src/commands/db/credentials.ts @@ -0,0 +1,227 @@ +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 DatabaseTarget { + databaseId: string | null; + host: string; + label: string; +} + +export interface ResolveCredentialsOptions { + url?: string; + token?: string; + databaseId?: string; + profile: string; + apiKey?: string; + 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:"]); +const DEFAULT_TLS_PORT = "443"; + +/** 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. + * + * 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 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 ( + 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 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 endpoint) still needs no network call. + */ +export function envTokenAllowedFor( + explicitUrl: string | undefined, + envUrl: string | undefined, +): boolean { + if (!envUrl) return false; + if (!isEncrypted(envUrl)) return false; + if (!explicitUrl) return true; + return sameEndpoint(explicitUrl, envUrl) && isEncrypted(explicitUrl); +} + +/** + * 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) + * + * An explicit database ID skips step 2 entirely: `.env` may describe a different + * database, and silently connecting there would target the wrong database. + * + * 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 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`. + */ +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 ?? envUrl; + let token = + opts.token ?? (envTokenAllowedFor(opts.url, envUrl) ? envToken : undefined); + + if (url) requireEncrypted(url); + + 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 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 an endpoint we'd refuse. + const { data } = await fetchDatabase(); + const canonical = data?.db?.url; + + if (!canonical) { + throw new UserError(`Could not fetch database ${databaseId}.`); + } + + if (!sameEndpoint(url, canonical)) { + throw new UserError( + `--url does not point at ${databaseId}.`, + `Use the URL provided by Bunny Database, 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."); + } + + requireEncrypted(url); + + 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..dd270eb3 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/apply.ts @@ -0,0 +1,292 @@ +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 { 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, + 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"; +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; +} + +/** + * 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_PATTERN, { + type: "string", + default: DEFAULT_MIGRATIONS_PATTERN, + describe: "Migration glob relative to --dir", + }) + .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", + }) + .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, + apiKey, + }) => { + const json = output === "json"; + + const { dir, detected } = resolveMigrationsDir(dirArg); + 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 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, databaseId } = 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 }); + 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[]) => + logger.log( + JSON.stringify( + { + dir: displayDir, + pattern, + table: MIGRATIONS_TABLE, + 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, + 2, + ), + ); + + if (pending.length === 0) { + if (json) { + report([]); + return; + } + logger.success( + issues.length === 0 ? "Already up to date." : "No pending migrations.", + ); + 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); + } + + 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([]); + 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 to ${target.label}?`, { + force: force || !isInteractive(output), + initial: true, + }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + await ensureMigrationsTable(client); + + 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, { + statements: prepared.get(file.name), + }); + spin.stop(); + done.push(file.name); + if (!json) { + logger.success( + `${file.name} (${statements} statement${statements === 1 ? "" : "s"})`, + ); + } + } catch (err: unknown) { + spin.stop(); + // 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} applied, ${remaining} still pending. Fix ${file.name} and re-run.`, + ); + } + } + + 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..26f90570 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/constants.ts @@ -0,0 +1,17 @@ +/** 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; + +/** 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 new file mode 100644 index 00000000..d21f1920 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/create.ts @@ -0,0 +1,107 @@ +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, + nextSequence, + resolveCreateMigrationsDir, + slugify, +} from "./engine.ts"; + +const COMMAND = "create [name]"; +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", "Prompt for a name"], + [ + "$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", + }) + .option(ARG_DIR, { + type: "string", + describe: "Migrations directory (default: migrations)", + }), + + 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 = resolveCreateMigrationsDir(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..d54ac809 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/drift.ts @@ -0,0 +1,71 @@ +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. + * + * `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(""); + 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}`); + } + + 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 new file mode 100644 index 00000000..f8184264 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/engine.test.ts @@ -0,0 +1,544 @@ +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 { assertMigrationHistorySafe, migrationHistoryIssues } from "./drift.ts"; +import { + applyMigration, + checksum, + discoverMigrations, + ensureMigrationsTable, + fetchApplied, + type MigrationClient, + migrationStatements, + migrationStatuses, + migrationsTableExists, + nextSequence, + pendingMigrations, + readApplied, + resolveCreateMigrationsDir, + 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("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/, + ); + }); + + 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, + }); + }); + + 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", () => { + 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", + }, + ]); + }); + + 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", () => { + 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("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); + + 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/, + ); + }); + + 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", () => { + 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("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(); + 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..2c1e1635 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/engine.ts @@ -0,0 +1,369 @@ +import { createHash } from "node:crypto"; +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"; + +/** 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" + | "out_of_order"; + +export interface MigrationStatus { + name: string; + state: MigrationState; + /** Set for states backed by an applied tracking row. */ + 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 }; +} + +/** + * 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 matching `pattern` in `dir`, sorted by relative path. + * + * 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, + pattern = DEFAULT_MIGRATIONS_PATTERN, +): MigrationFile[] { + if (!isDirectory(dir)) { + throw new UserError( + `Migrations directory not found: ${dir}`, + "Run `bunny db migrations create ` to create your first migration.", + ); + } + + validateMigrationPattern(pattern); + + const files: MigrationFile[] = []; + + 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; + 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), + })); +} + +/** + * 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. + * + * 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 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: + newestApplied && file.name < newestApplied + ? "out_of_order" + : "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)); +} + +/** 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. + * + * 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, + options: { table?: string; statements?: string[] } = {}, +): Promise<{ statements: number }> { + const table = options.table ?? MIGRATIONS_TABLE; + const statements = options.statements ?? migrationStatements(file); + + 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..8350ce76 --- /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. (experimental)", + [ + 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..6c00d1e9 --- /dev/null +++ b/packages/cli/src/commands/db/migrations/list.ts @@ -0,0 +1,190 @@ +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 { 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"; + +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", + 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; +} + +/** + * 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_PATTERN, { + type: "string", + default: DEFAULT_MIGRATIONS_PATTERN, + describe: "Migration glob relative to --dir", + }) + .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_PATTERN]: pattern = DEFAULT_MIGRATIONS_PATTERN, + [ARG_URL]: urlArg, + [ARG_TOKEN]: tokenArg, + profile, + output, + verbose, + apiKey, + }) => { + const { dir, detected } = resolveMigrationsDir(dirArg); + const files = discoverMigrations(dir, pattern); + const displayDir = relative(process.cwd(), dir) || "."; + + if (detected && output !== "json") { + logger.dim(`Using ${displayDir}`); + } + + const { url, token, databaseId } = await resolveCredentials({ + url: urlArg, + token: tokenArg, + databaseId: databaseIdArg, + profile, + apiKey, + verbose, + }); + + 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); + + const statuses = migrationStatuses(files, applied); + + if (output === "json") { + logger.log( + 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, + applied_at: s.appliedAt ?? null, + })), + }, + null, + 2, + ), + ); + return; + } + + if (statuses.length === 0) { + logger.info(`No migrations found in ${displayDir}.`); + 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; + } + + logger.log( + formatTable( + ["Migration", "State", "Applied"], + statuses.map((s) => [ + s.name, + STATE_LABELS[s.state], + s.appliedAt ?? "-", + ]), + output, + ), + ); + + const pending = pendingMigrations(files, applied).length; + const issues = migrationHistoryIssues(statuses).length; + logger.log(""); + logger.dim( + pending === 0 + ? issues === 0 + ? "Up to date." + : "No pending migrations; history needs attention." + : `${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..2c4e03b0 100644 --- a/packages/database-shell/src/parser.ts +++ b/packages/database-shell/src/parser.ts @@ -1,18 +1,77 @@ +/** 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; + +/** Quoted strings and identifiers, so keywords inside them don't affect nesting. */ +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}.`); +} + /** - * Split a SQL string into individual statements, handling single-quoted strings - * and `--` line comments. Trims whitespace and filters empty results. + * 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; + + 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; +} + +/** + * 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. */ 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; @@ -20,23 +79,36 @@ export function splitStatements(sql: string): string[] { 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; + // Handle /* */ block comments (only outside quotes) + if (ch === "/" && sql[i + 1] === "*") { + const close = sql.indexOf("*/", i + 2); + if (close === -1) { + throw syntaxError(sql, i, "Unterminated block comment"); } + i = close + 1; + current += " "; + continue; + } + + 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; + } const trimmed = current.trim(); if (trimmed.length > 0) statements.push(trimmed); current = ""; @@ -46,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 8d812772..d8dc6143 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';"; @@ -571,6 +591,97 @@ 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 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;"; + 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("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("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", () => { + 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`);"; + 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 1c0d77bd..c2ffcca6 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -37,6 +37,8 @@ bunny api GET /user 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 @@ -68,7 +70,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, custom domains, domain-gated previews, GitHub Actions)** -> `references/sites.md` diff --git a/skills/bunny-cli/references/database.md b/skills/bunny-cli/references/database.md index be9bc6e9..d8a05544 100644 --- a/skills/bunny-cli/references/database.md +++ b/skills/bunny-cli/references/database.md @@ -177,6 +177,14 @@ 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`. 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 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. + +The CLI targets hosted Bunny Database connections and does not provide a plaintext local-database exception. + ### REPL dot-commands In interactive mode, the shell supports dot-commands like `.tables`, `.schema`, `.fk`, etc. @@ -204,6 +212,72 @@ 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 (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 +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 apply --pattern "*/migration.sql" # apply a nested ORM layout +``` + +### `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 | +| `--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), `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 | +| `--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. 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 + +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 ```bash