Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/link-cancel-nonzero-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

Cancelling an interactive selection prompt now exits non-zero so scripts and CI can tell a cancelled command apart from a successful one. Previously `db link`, `db regions update`, and `scripts env remove` printed a "Cancelled." line and exited `0` when you aborted the picker with Ctrl-C/Esc, making a no-op indistinguishable from success. They now exit `1` (and emit a proper `{"error":…}` payload under `--output json`), matching `scripts link`, `apps link`, and the shared `resolveDbId` selection prompt. Declining a confirmation ("Delete?", "Replace?") still exits `0` — that's a deliberate answer, not an abort.
29 changes: 16 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,19 +233,22 @@ bunny-cli/
│ │ │ └── delete.ts # Remove a profile
│ │ ├── whoami.ts # Show authenticated account: name, email, profile (top-level: bunny whoami)
│ │ ├── db/
│ │ │ ├── index.ts # defineNamespace("db", ...) — registers all database commands
│ │ │ ├── constants.ts # Database status labels, region maps
│ │ │ ├── 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
│ │ │ ├── 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
│ │ │ ├── region-choices.ts # Shared: grouped region prompt choices by continent
│ │ │ ├── resolve-db.ts # Helper: resolve database ID from flag, manifest, .env, or interactive prompt
│ │ │ ├── shell.ts # Thin wrapper: credential resolution + delegates to @bunny.net/database-shell
│ │ │ ├── show.ts # Show database details (regions, size, status)
│ │ │ ├── usage.ts # Show database usage statistics
│ │ │ ├── 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
│ │ │ ├── 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
│ │ │ ├── quickstart-snippets.ts # Shared: per-language connection code snippets (TypeScript, Go, Rust, .NET)
│ │ │ ├── region-choices.ts # Shared: grouped region prompt choices by continent
│ │ │ ├── resolve-db.ts # Helper: resolve database ID from flag, manifest, .env, or interactive prompt
│ │ │ ├── shell.ts # Thin wrapper: credential resolution + delegates to @bunny.net/database-shell
│ │ │ ├── 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
│ │ │ ├── regions/
│ │ │ │ ├── index.ts # defineNamespace("regions", ...) — registers region commands
│ │ │ │ ├── add.ts # Add primary/replica regions (interactive multiselect or flags)
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/src/commands/db/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { createDbClient } from "@bunny.net/openapi-client";
Comment thread
jamie-at-bunny marked this conversation as resolved.
import type { components } from "@bunny.net/openapi-client/generated/database.d.ts";
import { UserError } from "../../core/errors.ts";
import { DB_PAGE_SIZE, TOKEN_TTL_MINUTES } from "./constants.ts";

type DbClient = ReturnType<typeof createDbClient>;
type Database = components["schemas"]["Database2"];
type RegionConfig = components["schemas"]["ListConfigAPIResponse"];
type GenerateTokenResponse =
components["schemas"]["GenerateTokenDatabaseV2Response"];
type TokenAuthorization =
components["schemas"]["GenerateTokenDatabaseV2Payload"]["authorization"];
type DBLiveStatus = components["schemas"]["DBLiveStatus"];

/** Fetch a single database by ID, throwing a UserError if it doesn't exist. */
export async function fetchDatabase(
client: DbClient,
id: string,
): Promise<Database> {
const { data } = await client.GET("/v2/databases/{db_id}", {
params: { path: { db_id: id } },
});
if (!data?.db) throw new UserError(`Database ${id} not found.`);
return data.db;
}

/** Fetch every database in the account, paginating until exhausted. */
export async function fetchAllDatabases(client: DbClient): Promise<Database[]> {
const all: Database[] = [];
let page = 1;

while (true) {
const { data } = await client.GET("/v2/databases", {
params: { query: { page, per_page: DB_PAGE_SIZE } },
});

all.push(...(data?.databases ?? []));

if (!data?.page_info?.has_more_items) break;
page++;
}

return all;
}

/** Fetch the global region configuration, throwing if unavailable. */
export async function fetchRegionConfig(
client: DbClient,
): Promise<RegionConfig> {
const { data } = await client.GET("/v1/config", { params: {} });
if (!data) throw new UserError("Could not fetch region configuration.");
return data;
}

/** Fetch a database and the region config in parallel. */
export async function fetchDatabaseWithRegions(
client: DbClient,
id: string,
): Promise<{ db: Database; config: RegionConfig }> {
const [db, config] = await Promise.all([
fetchDatabase(client, id),
fetchRegionConfig(client),
]);
return { db, config };
}

/** Build a region id → display name lookup from the region config. */
export function regionNameMap(config: RegionConfig): Map<string, string> {
const map = new Map<string, string>();
for (const r of [...config.primary_regions, ...config.replica_regions]) {
map.set(r.id, r.name);
}
return map;
}

/** Generate an auth token for a database. */
export async function generateToken(
client: DbClient,
id: string,
opts: { authorization: TokenAuthorization; expiresAt: string | null },
): Promise<GenerateTokenResponse | undefined> {
const { data } = await client.PUT("/v2/databases/{db_id}/auth/generate", {
params: { path: { db_id: id } },
body: { authorization: opts.authorization, expires_at: opts.expiresAt },
});
return data;
}

/** RFC 3339 timestamp `minutes` from now (defaults to the token TTL). */
export function tokenExpiryFromNow(minutes = TOKEN_TTL_MINUTES): string {
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
}

/** Fetch live status metrics for the given database IDs. */
export async function fetchLiveStatus(
client: DbClient,
ids: string[],
): Promise<Record<string, DBLiveStatus>> {
const { data } = await client.POST("/v1/live/live_db", {
body: { db_ids: ids },
});
return data?.live_metrics ?? {};
}

/** "Active" when the database is live, otherwise "Idle". */
export function liveStatusLabel(live: DBLiveStatus | undefined): string {
return live?.state === "Live" ? "Active" : "Idle";
}

/** Primary region code from live metadata, or null when not live. */
export function liveMainRegion(live: DBLiveStatus | undefined): string | null {
return live?.state === "Live" ? live.metadata.main : null;
}
6 changes: 6 additions & 0 deletions packages/cli/src/commands/db/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN";
/** Filename for the linked-database manifest stored under `.bunny/`. */
export const DATABASE_MANIFEST = "database.json";

/** Page size used when paginating the database list endpoint. */
export const DB_PAGE_SIZE = 100;

/** Default lifetime for tokens minted by `db shell` / `db studio`. */
export const TOKEN_TTL_MINUTES = 30;

/** Shape of `.bunny/database.json`. */
export interface DatabaseManifest {
id: string;
Expand Down
22 changes: 7 additions & 15 deletions packages/cli/src/commands/db/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { logger } from "../../core/logger.ts";
import { loadManifest, saveManifest } from "../../core/manifest.ts";
import { confirm, spinner } from "../../core/ui.ts";
import { readEnvValue, writeEnvValue } from "../../utils/env-file.ts";
import { fetchRegionConfig, generateToken } from "./api.ts";
import {
DATABASE_MANIFEST,
type DatabaseManifest,
Expand Down Expand Up @@ -146,16 +147,10 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
const configSpin = spinner("Fetching available regions...");
configSpin.start();

const { data: regionConfig } = await client.GET("/v1/config", {
params: {},
});
const regionConfig = await fetchRegionConfig(client);

configSpin.stop();

if (!regionConfig) {
throw new UserError("Could not fetch region configuration.");
}

const storageRegions = regionConfig.storage_region_available;
const availablePrimary = regionConfig.primary_regions;
const availableReplicas = regionConfig.replica_regions;
Expand Down Expand Up @@ -258,7 +253,7 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
message: "Database location:",
choices,
initial: preselected
? choices.findIndex((c: any) => c.value === preselected)
? choices.findIndex((c) => c.value === preselected)
: 0,
});
if (!location) throw new UserError("Location is required.");
Expand Down Expand Up @@ -394,13 +389,10 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
const tokenSpin = spinner("Generating token...");
tokenSpin.start();

const { data: tokenData } = await client.PUT(
"/v2/databases/{db_id}/auth/generate",
{
params: { path: { db_id: data.db_id } },
body: { authorization: "full-access", expires_at: null },
},
);
const tokenData = await generateToken(client, data.db_id, {
authorization: "full-access",
expiresAt: null,
});

tokenSpin.stop();

Expand Down
9 changes: 2 additions & 7 deletions packages/cli/src/commands/db/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import prompts from "prompts";
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 { loadManifest, removeManifest } from "../../core/manifest.ts";
import { confirm, spinner } from "../../core/ui.ts";
import { readEnvValue, removeEnvValue } from "../../utils/env-file.ts";
import { fetchDatabase } from "./api.ts";
import {
ARG_DATABASE_ID,
DATABASE_MANIFEST,
Expand Down Expand Up @@ -93,15 +93,10 @@ export const dbDeleteCommand = defineCommand<DeleteArgs>({
const fetchSpin = spinner("Fetching database...");
fetchSpin.start();

const { data } = await client.GET("/v2/databases/{db_id}", {
params: { path: { db_id: databaseId } },
});
const db = await fetchDatabase(client, databaseId);

fetchSpin.stop();

const db = data?.db;
if (!db) throw new UserError(`Database ${databaseId} not found.`);

if (source === "env") {
logger.dim(`Database: ${db.name} (${databaseId}, from .env)`);
} else if (source === "manifest") {
Expand Down
30 changes: 4 additions & 26 deletions packages/cli/src/commands/db/link.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createDbClient } from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/database.d.ts";
import prompts from "prompts";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
Expand All @@ -8,14 +7,13 @@ import { UserError } from "../../core/errors.ts";
import { logger } from "../../core/logger.ts";
import { saveManifest } from "../../core/manifest.ts";
import { spinner } from "../../core/ui.ts";
import { fetchAllDatabases, fetchDatabase } from "./api.ts";
import {
ARG_DATABASE_ID,
DATABASE_MANIFEST,
type DatabaseManifest,
} from "./constants.ts";

type Database = Pick<components["schemas"]["Database2"], "id" | "name">;

const COMMAND = `link [${ARG_DATABASE_ID}]`;
const DESCRIPTION = "Link the current directory to a database.";

Expand Down Expand Up @@ -66,17 +64,10 @@ export const dbLinkCommand = defineCommand<LinkArgs>({
const spin = spinner("Fetching database...");
spin.start();

const { data } = await client.GET("/v2/databases/{db_id}", {
params: { path: { db_id: databaseIdArg } },
});
const db = await fetchDatabase(client, databaseIdArg);

spin.stop();

const db = data?.db;
if (!db) {
throw new UserError(`Database ${databaseIdArg} not found.`);
}

saveManifest<DatabaseManifest>(DATABASE_MANIFEST, {
id: db.id,
name: db.name,
Expand All @@ -94,19 +85,7 @@ export const dbLinkCommand = defineCommand<LinkArgs>({
const spin = spinner("Fetching databases...");
spin.start();

const allDatabases: Database[] = [];
let page = 1;

while (true) {
const { data } = await client.GET("/v2/databases", {
params: { query: { page, per_page: 100 } },
});

allDatabases.push(...(data?.databases ?? []));

if (!data?.page_info?.has_more_items) break;
page++;
}
const allDatabases = await fetchAllDatabases(client);

spin.stop();

Expand All @@ -130,8 +109,7 @@ export const dbLinkCommand = defineCommand<LinkArgs>({
});

if (!selected) {
logger.log("Link cancelled.");
process.exit(1);
throw new UserError("Link cancelled.");
}

saveManifest<DatabaseManifest>(DATABASE_MANIFEST, {
Expand Down
Loading