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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/db-migrations.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.bunny
bunny
bsql

# Throwaway local testing
.test
307 changes: 195 additions & 112 deletions AGENTS.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ bun ny <command>
# 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
Expand Down Expand Up @@ -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
Expand Down
216 changes: 216 additions & 0 deletions packages/cli/src/commands/db/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading