Skip to content

Commit aa0f44d

Browse files
refactor(cli/db): extract shared helpers and quickstart snippets (#88)
* refactor(cli/db): extract shared helpers and quickstart snippets * fix(cli): exit non-zero when an interactive selection is cancelled * docs: agents
1 parent ddbe156 commit aa0f44d

22 files changed

Lines changed: 393 additions & 397 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bunny.net/cli": patch
3+
---
4+
5+
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.

AGENTS.md

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -233,19 +233,22 @@ bunny-cli/
233233
│ │ │ └── delete.ts # Remove a profile
234234
│ │ ├── whoami.ts # Show authenticated account: name, email, profile (top-level: bunny whoami)
235235
│ │ ├── db/
236-
│ │ │ ├── index.ts # defineNamespace("db", ...) — registers all database commands
237-
│ │ │ ├── constants.ts # Database status labels, region maps
238-
│ │ │ ├── create.ts # Create a new database (interactive region selection or flags)
239-
│ │ │ ├── delete.ts # Delete a database (double confirmation or --force)
240-
│ │ │ ├── docs.ts # Open database documentation in browser
241-
│ │ │ ├── link.ts # Link directory to a database (.bunny/database.json)
242-
│ │ │ ├── list.ts # List all databases
243-
│ │ │ ├── quickstart.ts # Generate quickstart guide for connecting to a database
244-
│ │ │ ├── region-choices.ts # Shared: grouped region prompt choices by continent
245-
│ │ │ ├── resolve-db.ts # Helper: resolve database ID from flag, manifest, .env, or interactive prompt
246-
│ │ │ ├── shell.ts # Thin wrapper: credential resolution + delegates to @bunny.net/database-shell
247-
│ │ │ ├── show.ts # Show database details (regions, size, status)
248-
│ │ │ ├── usage.ts # Show database usage statistics
236+
│ │ │ ├── index.ts # defineNamespace("db", ...) — registers all database commands
237+
│ │ │ ├── constants.ts # Database status labels, region maps
238+
│ │ │ ├── api.ts # Shared: typed v2 database/token API calls (fetchDatabase, fetchAllDatabases, generateToken, fetchLiveStatus, …)
239+
│ │ │ ├── create.ts # Create a new database (interactive region selection or flags)
240+
│ │ │ ├── delete.ts # Delete a database (double confirmation or --force)
241+
│ │ │ ├── docs.ts # Open database documentation in browser
242+
│ │ │ ├── link.ts # Link directory to a database (.bunny/database.json)
243+
│ │ │ ├── list.ts # List all databases
244+
│ │ │ ├── quickstart.ts # Generate quickstart guide for connecting to a database
245+
│ │ │ ├── quickstart-snippets.ts # Shared: per-language connection code snippets (TypeScript, Go, Rust, .NET)
246+
│ │ │ ├── region-choices.ts # Shared: grouped region prompt choices by continent
247+
│ │ │ ├── resolve-db.ts # Helper: resolve database ID from flag, manifest, .env, or interactive prompt
248+
│ │ │ ├── shell.ts # Thin wrapper: credential resolution + delegates to @bunny.net/database-shell
249+
│ │ │ ├── show.ts # Show database details (regions, size, status)
250+
│ │ │ ├── studio.ts # Open a visual database explorer in the browser (local web UI)
251+
│ │ │ ├── usage.ts # Show database usage statistics
249252
│ │ │ ├── regions/
250253
│ │ │ │ ├── index.ts # defineNamespace("regions", ...) — registers region commands
251254
│ │ │ │ ├── add.ts # Add primary/replica regions (interactive multiselect or flags)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { createDbClient } from "@bunny.net/openapi-client";
2+
import type { components } from "@bunny.net/openapi-client/generated/database.d.ts";
3+
import { UserError } from "../../core/errors.ts";
4+
import { DB_PAGE_SIZE, TOKEN_TTL_MINUTES } from "./constants.ts";
5+
6+
type DbClient = ReturnType<typeof createDbClient>;
7+
type Database = components["schemas"]["Database2"];
8+
type RegionConfig = components["schemas"]["ListConfigAPIResponse"];
9+
type GenerateTokenResponse =
10+
components["schemas"]["GenerateTokenDatabaseV2Response"];
11+
type TokenAuthorization =
12+
components["schemas"]["GenerateTokenDatabaseV2Payload"]["authorization"];
13+
type DBLiveStatus = components["schemas"]["DBLiveStatus"];
14+
15+
/** Fetch a single database by ID, throwing a UserError if it doesn't exist. */
16+
export async function fetchDatabase(
17+
client: DbClient,
18+
id: string,
19+
): Promise<Database> {
20+
const { data } = await client.GET("/v2/databases/{db_id}", {
21+
params: { path: { db_id: id } },
22+
});
23+
if (!data?.db) throw new UserError(`Database ${id} not found.`);
24+
return data.db;
25+
}
26+
27+
/** Fetch every database in the account, paginating until exhausted. */
28+
export async function fetchAllDatabases(client: DbClient): Promise<Database[]> {
29+
const all: Database[] = [];
30+
let page = 1;
31+
32+
while (true) {
33+
const { data } = await client.GET("/v2/databases", {
34+
params: { query: { page, per_page: DB_PAGE_SIZE } },
35+
});
36+
37+
all.push(...(data?.databases ?? []));
38+
39+
if (!data?.page_info?.has_more_items) break;
40+
page++;
41+
}
42+
43+
return all;
44+
}
45+
46+
/** Fetch the global region configuration, throwing if unavailable. */
47+
export async function fetchRegionConfig(
48+
client: DbClient,
49+
): Promise<RegionConfig> {
50+
const { data } = await client.GET("/v1/config", { params: {} });
51+
if (!data) throw new UserError("Could not fetch region configuration.");
52+
return data;
53+
}
54+
55+
/** Fetch a database and the region config in parallel. */
56+
export async function fetchDatabaseWithRegions(
57+
client: DbClient,
58+
id: string,
59+
): Promise<{ db: Database; config: RegionConfig }> {
60+
const [db, config] = await Promise.all([
61+
fetchDatabase(client, id),
62+
fetchRegionConfig(client),
63+
]);
64+
return { db, config };
65+
}
66+
67+
/** Build a region id → display name lookup from the region config. */
68+
export function regionNameMap(config: RegionConfig): Map<string, string> {
69+
const map = new Map<string, string>();
70+
for (const r of [...config.primary_regions, ...config.replica_regions]) {
71+
map.set(r.id, r.name);
72+
}
73+
return map;
74+
}
75+
76+
/** Generate an auth token for a database. */
77+
export async function generateToken(
78+
client: DbClient,
79+
id: string,
80+
opts: { authorization: TokenAuthorization; expiresAt: string | null },
81+
): Promise<GenerateTokenResponse | undefined> {
82+
const { data } = await client.PUT("/v2/databases/{db_id}/auth/generate", {
83+
params: { path: { db_id: id } },
84+
body: { authorization: opts.authorization, expires_at: opts.expiresAt },
85+
});
86+
return data;
87+
}
88+
89+
/** RFC 3339 timestamp `minutes` from now (defaults to the token TTL). */
90+
export function tokenExpiryFromNow(minutes = TOKEN_TTL_MINUTES): string {
91+
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
92+
}
93+
94+
/** Fetch live status metrics for the given database IDs. */
95+
export async function fetchLiveStatus(
96+
client: DbClient,
97+
ids: string[],
98+
): Promise<Record<string, DBLiveStatus>> {
99+
const { data } = await client.POST("/v1/live/live_db", {
100+
body: { db_ids: ids },
101+
});
102+
return data?.live_metrics ?? {};
103+
}
104+
105+
/** "Active" when the database is live, otherwise "Idle". */
106+
export function liveStatusLabel(live: DBLiveStatus | undefined): string {
107+
return live?.state === "Live" ? "Active" : "Idle";
108+
}
109+
110+
/** Primary region code from live metadata, or null when not live. */
111+
export function liveMainRegion(live: DBLiveStatus | undefined): string | null {
112+
return live?.state === "Live" ? live.metadata.main : null;
113+
}

packages/cli/src/commands/db/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN";
1010
/** Filename for the linked-database manifest stored under `.bunny/`. */
1111
export const DATABASE_MANIFEST = "database.json";
1212

13+
/** Page size used when paginating the database list endpoint. */
14+
export const DB_PAGE_SIZE = 100;
15+
16+
/** Default lifetime for tokens minted by `db shell` / `db studio`. */
17+
export const TOKEN_TTL_MINUTES = 30;
18+
1319
/** Shape of `.bunny/database.json`. */
1420
export interface DatabaseManifest {
1521
id: string;

packages/cli/src/commands/db/create.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { logger } from "../../core/logger.ts";
1010
import { loadManifest, saveManifest } from "../../core/manifest.ts";
1111
import { confirm, spinner } from "../../core/ui.ts";
1212
import { readEnvValue, writeEnvValue } from "../../utils/env-file.ts";
13+
import { fetchRegionConfig, generateToken } from "./api.ts";
1314
import {
1415
DATABASE_MANIFEST,
1516
type DatabaseManifest,
@@ -146,16 +147,10 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
146147
const configSpin = spinner("Fetching available regions...");
147148
configSpin.start();
148149

149-
const { data: regionConfig } = await client.GET("/v1/config", {
150-
params: {},
151-
});
150+
const regionConfig = await fetchRegionConfig(client);
152151

153152
configSpin.stop();
154153

155-
if (!regionConfig) {
156-
throw new UserError("Could not fetch region configuration.");
157-
}
158-
159154
const storageRegions = regionConfig.storage_region_available;
160155
const availablePrimary = regionConfig.primary_regions;
161156
const availableReplicas = regionConfig.replica_regions;
@@ -258,7 +253,7 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
258253
message: "Database location:",
259254
choices,
260255
initial: preselected
261-
? choices.findIndex((c: any) => c.value === preselected)
256+
? choices.findIndex((c) => c.value === preselected)
262257
: 0,
263258
});
264259
if (!location) throw new UserError("Location is required.");
@@ -394,13 +389,10 @@ export const dbCreateCommand = defineCommand<CreateArgs>({
394389
const tokenSpin = spinner("Generating token...");
395390
tokenSpin.start();
396391

397-
const { data: tokenData } = await client.PUT(
398-
"/v2/databases/{db_id}/auth/generate",
399-
{
400-
params: { path: { db_id: data.db_id } },
401-
body: { authorization: "full-access", expires_at: null },
402-
},
403-
);
392+
const tokenData = await generateToken(client, data.db_id, {
393+
authorization: "full-access",
394+
expiresAt: null,
395+
});
404396

405397
tokenSpin.stop();
406398

packages/cli/src/commands/db/delete.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import prompts from "prompts";
33
import { resolveConfig } from "../../config/index.ts";
44
import { clientOptions } from "../../core/client-options.ts";
55
import { defineCommand } from "../../core/define-command.ts";
6-
import { UserError } from "../../core/errors.ts";
76
import { logger } from "../../core/logger.ts";
87
import { loadManifest, removeManifest } from "../../core/manifest.ts";
98
import { confirm, spinner } from "../../core/ui.ts";
109
import { readEnvValue, removeEnvValue } from "../../utils/env-file.ts";
10+
import { fetchDatabase } from "./api.ts";
1111
import {
1212
ARG_DATABASE_ID,
1313
DATABASE_MANIFEST,
@@ -93,15 +93,10 @@ export const dbDeleteCommand = defineCommand<DeleteArgs>({
9393
const fetchSpin = spinner("Fetching database...");
9494
fetchSpin.start();
9595

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

10098
fetchSpin.stop();
10199

102-
const db = data?.db;
103-
if (!db) throw new UserError(`Database ${databaseId} not found.`);
104-
105100
if (source === "env") {
106101
logger.dim(`Database: ${db.name} (${databaseId}, from .env)`);
107102
} else if (source === "manifest") {

packages/cli/src/commands/db/link.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createDbClient } from "@bunny.net/openapi-client";
2-
import type { components } from "@bunny.net/openapi-client/generated/database.d.ts";
32
import prompts from "prompts";
43
import { resolveConfig } from "../../config/index.ts";
54
import { clientOptions } from "../../core/client-options.ts";
@@ -8,14 +7,13 @@ import { UserError } from "../../core/errors.ts";
87
import { logger } from "../../core/logger.ts";
98
import { saveManifest } from "../../core/manifest.ts";
109
import { spinner } from "../../core/ui.ts";
10+
import { fetchAllDatabases, fetchDatabase } from "./api.ts";
1111
import {
1212
ARG_DATABASE_ID,
1313
DATABASE_MANIFEST,
1414
type DatabaseManifest,
1515
} from "./constants.ts";
1616

17-
type Database = Pick<components["schemas"]["Database2"], "id" | "name">;
18-
1917
const COMMAND = `link [${ARG_DATABASE_ID}]`;
2018
const DESCRIPTION = "Link the current directory to a database.";
2119

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

69-
const { data } = await client.GET("/v2/databases/{db_id}", {
70-
params: { path: { db_id: databaseIdArg } },
71-
});
67+
const db = await fetchDatabase(client, databaseIdArg);
7268

7369
spin.stop();
7470

75-
const db = data?.db;
76-
if (!db) {
77-
throw new UserError(`Database ${databaseIdArg} not found.`);
78-
}
79-
8071
saveManifest<DatabaseManifest>(DATABASE_MANIFEST, {
8172
id: db.id,
8273
name: db.name,
@@ -94,19 +85,7 @@ export const dbLinkCommand = defineCommand<LinkArgs>({
9485
const spin = spinner("Fetching databases...");
9586
spin.start();
9687

97-
const allDatabases: Database[] = [];
98-
let page = 1;
99-
100-
while (true) {
101-
const { data } = await client.GET("/v2/databases", {
102-
params: { query: { page, per_page: 100 } },
103-
});
104-
105-
allDatabases.push(...(data?.databases ?? []));
106-
107-
if (!data?.page_info?.has_more_items) break;
108-
page++;
109-
}
88+
const allDatabases = await fetchAllDatabases(client);
11089

11190
spin.stop();
11291

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

132111
if (!selected) {
133-
logger.log("Link cancelled.");
134-
process.exit(1);
112+
throw new UserError("Link cancelled.");
135113
}
136114

137115
saveManifest<DatabaseManifest>(DATABASE_MANIFEST, {

0 commit comments

Comments
 (0)