db-drifter-cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +99 -2
  2. package/package.json +35 -5
  3. package/src/index.js +652 -0
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ # dbd — db_drifter CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Plain ESM JavaScript (runs on **Node ≥ 18** or **Bun**). No build step.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm i -g db-drifter-cli # gives you the `dbd` command
9
+ # or, per-project / one-off:
10
+ npx db-drifter-cli --help
11
+
12
+ # from a checkout (no install):
13
+ node cli/src/index.js --help
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ dbd auth # browser sign-in; auto-selects your workspace (prompts if you have several)
20
+
21
+ dbd use staging # pick the source database (auto if the workspace has just one)
22
+
23
+ dbd branch pr-42 # create an isolated, writable database and print its DSN
24
+ # postgresql://you@wire.db-drifter.com/ws1.staging.pr-42?sslmode=require
25
+
26
+ psql "$(dbd conn staging --branch pr-42)" # …or any Postgres client / your app
27
+ dbd branch rm pr-42 # throw it away
28
+ ```
29
+
30
+ `dbd auth` auto-selects your workspace when you have one (and prompts a
31
+ radio list when you have several), then remembers a single source database.
32
+ `dbd branch <name>` creates an isolated overlay on that source and prints its
33
+ connection string — hand it straight to your app, tests or an agent. Run
34
+ `dbd add-source <name> <url>` first if you're an admin connecting a database.
35
+
36
+ ```sh
37
+ dbd conn staging # the `main` branch DSN
38
+ dbd conn staging --branch pr-42
39
+ dbd sql staging "select count(*) from orders"
40
+ dbd branch ls
41
+ dbd use staging # default source database for `dbd branch`
42
+ ```
43
+
44
+ ## All commands
45
+
46
+ ```
47
+ auth [--username U] [--password P] [--token T] log in (device flow by default)
48
+ logout | whoami
49
+ use <source-db> set the default source database
50
+
51
+ add-source <name> <url> connect a source database (admin)
52
+ source-db ls | add <name> <url> | rm <name> | reset <name>
53
+
54
+ branch <name> [--source S] [--ttl-days N] create a branch, print its DSN
55
+ branch ls | rm <name>
56
+
57
+ conn <source-db> [--branch <name>] print a connection string
58
+ sql <source-db> [--branch <name>] <query> run SQL over the wire
59
+ workspace ls | use <id>
60
+ token ls | create <name> [--scopes a,b] [--workspace-id N] [--expires-in-days N] | rm <id>
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ - Credentials: `~/.config/db_drifter/config.json` (mode 0600). The stored
66
+ password is reused by `dbd sql`; the token (or `DBDRIFTER_TOKEN`) is used by
67
+ every other call.
68
+ - Environment: `DBDRIFTER_API_URL` (default `https://app.db-drifter.com`),
69
+ `DBDRIFTER_TOKEN` (wins over the stored token), `XDG_CONFIG_HOME`.
70
+ - There is no `listen` tunnel: the wire host is reachable, so `dbd conn` prints
71
+ a DSN you can give any Postgres client.
72
+
73
+ ## Notes
74
+
75
+ - `dbd sql` uses the [`postgres`](https://github.com/porsager/postgres) driver
76
+ (`ssl: require`). It works for everyday queries; a few exotic column types
77
+ aren't parsed by every driver — use `dbd conn` with `psql` if you hit one.
78
+ - `dbd token create <name> --scopes branch` prints just the secret, so
79
+ `dbd token create ci --scopes branch > token.txt` works.
80
+
81
+ ## CI: a database branch per pull request
82
+
83
+ 1. Create a branch-scoped token once:
84
+
85
+ ```sh
86
+ dbd token create ci --scopes branch
87
+ ```
88
+
89
+ 2. Copy `examples/github-actions-preview.yml` into your app repo as
90
+ `.github/workflows/db-drifter-preview.yml`, and set `DBDRIFTER_API_URL`,
91
+ `DBDRIFTER_SOURCE_DB` (vars) and `DBDRIFTER_TOKEN` (secret).
92
+
93
+ The workflow creates (or reuses) a branch named `pr-<number>`, exports its DSN
94
+ as `DATABASE_URL`, and deletes it on PR close. Ephemeral branches also carry a
95
+ TTL; the server's nightly cleanup removes expired branches and drops their
96
+ overlays.
97
+
98
+ The token can create/delete branches (scope `branch`) but not add source
99
+ databases (scope `admin`) — CI can provision its own database without being able
100
+ to widen access.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
- "name": "db-drifter-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "db-drifter-cli",
3
+ "version": "0.3.0",
4
+ "description": "db_drifter command-line client — connect a source database, create isolated branches, print connection strings.",
5
+ "type": "module",
6
+ "bin": { "dbd": "src/index.js" },
7
+ "files": ["src", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "keywords": [
10
+ "db_drifter",
11
+ "dbd",
12
+ "postgres",
13
+ "postgresql",
14
+ "database",
15
+ "database-branching",
16
+ "cli"
17
+ ],
18
+ "license": "MIT",
19
+ "homepage": "https://db-drifter.com",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/M-Dahab/db_drifter.git",
23
+ "directory": "cli"
24
+ },
25
+ "dependencies": {
26
+ "postgres": "^3.4.9"
27
+ },
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org",
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "start": "node src/index.js",
34
+ "build": "bun build --compile --outfile dbd src/index.js"
35
+ }
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dbd — the db_drifter command-line client.
4
+ *
5
+ * Plain ESM JavaScript so it runs anywhere: `npx dbd`, `npm i -g dbd`, or
6
+ * `bun src/index.js`. No build step. The only dependency (`postgres`) is loaded
7
+ * lazily, and only by `dbd sql`.
8
+ */
9
+
10
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+
14
+ const DEFAULT_API = "https://app.db-drifter.com";
15
+ const VERSION = "0.3.0";
16
+
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Config
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function configPath() {
24
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
25
+ return join(base, "db_drifter", "config.json");
26
+ }
27
+
28
+ function loadConfig() {
29
+ try {
30
+ return { api_url: DEFAULT_API, ...JSON.parse(readFileSync(configPath(), "utf8")) };
31
+ } catch {
32
+ return { api_url: DEFAULT_API };
33
+ }
34
+ }
35
+
36
+ function saveConfig(cfg) {
37
+ const p = configPath();
38
+ mkdirSync(dirname(p), { recursive: true });
39
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
40
+ try {
41
+ chmodSync(p, 0o600);
42
+ } catch {
43
+ /* best effort */
44
+ }
45
+ }
46
+
47
+ /** `DBDRIFTER_TOKEN` wins over the stored token. */
48
+ function tokenOf(cfg) {
49
+ const env = (process.env.DBDRIFTER_TOKEN || "").trim();
50
+ return env.length > 0 ? env : cfg.token || null;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // API client
55
+ // ---------------------------------------------------------------------------
56
+
57
+ class Api {
58
+ constructor(base, cookie, token) {
59
+ this.base = base;
60
+ this.cookie = cookie;
61
+ this.token = token;
62
+ }
63
+
64
+ headers(hasBody) {
65
+ const h = { "user-agent": `dbd/${VERSION}` };
66
+ if (hasBody) h["content-type"] = "application/json";
67
+ if (this.token) h["authorization"] = `Bearer ${this.token}`;
68
+ else if (this.cookie) h["cookie"] = this.cookie;
69
+ return h;
70
+ }
71
+
72
+ async req(method, path, body) {
73
+ const res = await fetch(this.base + path, {
74
+ method,
75
+ headers: this.headers(body !== undefined),
76
+ body: body === undefined ? undefined : JSON.stringify(body)
77
+ });
78
+ const text = await res.text();
79
+ let val = null;
80
+ try {
81
+ val = text ? JSON.parse(text) : null;
82
+ } catch {
83
+ val = null;
84
+ }
85
+ if (!res.ok) throw new Error(`${(val && val.error) || text || res.statusText} (${res.status})`);
86
+ return val;
87
+ }
88
+
89
+ get(p) {
90
+ return this.req("GET", p);
91
+ }
92
+ post(p, b) {
93
+ return this.req("POST", p, b ?? {});
94
+ }
95
+ del(p) {
96
+ return this.req("DELETE", p);
97
+ }
98
+ }
99
+
100
+ function apiFor(cfg) {
101
+ return new Api(cfg.api_url, cfg.cookie, tokenOf(cfg));
102
+ }
103
+
104
+ async function login(base, username, password) {
105
+ const res = await fetch(`${base.replace(/\/$/, "")}/api/auth/login`, {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json", "user-agent": `dbd/${VERSION}` },
108
+ body: JSON.stringify({ username, password })
109
+ });
110
+ const text = await res.text();
111
+ if (!res.ok) {
112
+ let msg = text;
113
+ try {
114
+ msg = JSON.parse(text).error ?? text;
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ throw new Error(`login failed: ${msg} (${res.status})`);
119
+ }
120
+ const cookie = res.headers.get("set-cookie")?.split(";")[0];
121
+ if (!cookie) throw new Error("login succeeded but no session cookie was returned");
122
+ return cookie;
123
+ }
124
+
125
+ async function resolveWorkspace(cfg, api) {
126
+ if (cfg.workspace_id) return cfg.workspace_id;
127
+ const v = await api.get("/api/workspaces");
128
+ const id = v?.workspaces?.[0]?.id;
129
+ if (!id) throw new Error("no workspace found; run `dbd workspace ls`");
130
+ return id;
131
+ }
132
+
133
+ async function workspaceDetail(api, ws) {
134
+ return api.get(`/api/workspaces/${ws}`);
135
+ }
136
+
137
+ async function findSourceDb(api, ws, name) {
138
+ const v = await workspaceDetail(api, ws);
139
+ const found = (v?.source_dbs ?? []).find((s) => s.name === name);
140
+ if (!found) throw new Error(`source database "${name}" not found`);
141
+ return found;
142
+ }
143
+
144
+ /** Resolve which source_db a bare `branch`/`use` refers to. */
145
+ async function resolveSourceDb(cfg, api, ws, explicit) {
146
+ if (explicit) return explicit;
147
+ if (cfg.source_db) return cfg.source_db;
148
+ const v = await workspaceDetail(api, ws);
149
+ const names = (v?.source_dbs ?? []).map((s) => s.name);
150
+ if (names.length === 1) return names[0];
151
+ if (names.length === 0) throw new Error("no source database yet; run `dbd add-source <name> <url>`");
152
+ const sds = v.source_dbs;
153
+ const picked = await chooseRadio("Select a source database:", sds, (s) => s.name);
154
+ if (picked) {
155
+ cfg.source_db = picked.name;
156
+ saveConfig(cfg);
157
+ return picked.name;
158
+ }
159
+ throw new Error(`which source database? pass --source <name> (available: ${names.join(", ")})`);
160
+ }
161
+
162
+ async function connFor(api, ws, sourceDb, branch) {
163
+ const sd = await findSourceDb(api, ws, sourceDb);
164
+ if (!branch) return sd.conn;
165
+ const b = (sd.branches ?? []).find((x) => x.name === branch);
166
+ if (!b) throw new Error(`branch "${branch}" not found on "${sourceDb}"`);
167
+ return b.conn;
168
+ }
169
+
170
+ const enc = (s) => encodeURIComponent(s);
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Args
174
+ // ---------------------------------------------------------------------------
175
+
176
+ function parseArgs(argv) {
177
+ const out = { pos: [], flags: {} };
178
+ for (let i = 0; i < argv.length; i++) {
179
+ const a = argv[i];
180
+ if (a.startsWith("--")) {
181
+ const eq = a.indexOf("=");
182
+ if (eq > -1) out.flags[a.slice(2, eq)] = a.slice(eq + 1);
183
+ else {
184
+ const key = a.slice(2);
185
+ const next = argv[i + 1];
186
+ if (next !== undefined && !next.startsWith("--")) {
187
+ out.flags[key] = next;
188
+ i++;
189
+ } else out.flags[key] = true;
190
+ }
191
+ } else out.pos.push(a);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ const str = (v) => (typeof v === "string" ? v : undefined);
197
+ const num = (v) => {
198
+ const s = str(v);
199
+ if (s === undefined) return undefined;
200
+ const n = Number(s);
201
+ return Number.isFinite(n) ? n : undefined;
202
+ };
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Hidden password prompt
206
+ // ---------------------------------------------------------------------------
207
+
208
+ async function promptPassword(prompt) {
209
+ process.stdout.write(prompt);
210
+ const input = process.stdin;
211
+ const canRaw = typeof input.setRawMode === "function";
212
+ if (canRaw) input.setRawMode(true);
213
+ input.resume();
214
+ return await new Promise((resolve) => {
215
+ let buf = "";
216
+ const onData = (chunk) => {
217
+ for (const ch of chunk.toString("utf8")) {
218
+ if (ch === "\r" || ch === "\n") {
219
+ input.off("data", onData);
220
+ if (canRaw) input.setRawMode(false);
221
+ input.pause();
222
+ process.stdout.write("\n");
223
+ resolve(buf);
224
+ return;
225
+ }
226
+ if (ch === "\u0003") process.exit(130);
227
+ if (ch === "\u007f") buf = buf.slice(0, -1);
228
+ else buf += ch;
229
+ }
230
+ };
231
+ input.on("data", onData);
232
+ });
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Interactive selection
237
+ // ---------------------------------------------------------------------------
238
+
239
+ const isTTY = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
240
+
241
+ /**
242
+ * Radio-style selector: ↑/↓ (or j/k, or a number) then Enter. Returns the
243
+ * chosen item, or null when there is no TTY to prompt on.
244
+ */
245
+ async function chooseRadio(question, items, label) {
246
+ if (!isTTY() || items.length === 0) return null;
247
+ let idx = 0;
248
+ const draw = (first) => {
249
+ if (!first) process.stdout.write(`\x1b[${items.length}A`);
250
+ for (let i = 0; i < items.length; i++) {
251
+ const mark = i === idx ? "❯" : " ";
252
+ process.stdout.write(`\x1b[K ${mark} ${label(items[i], i)}\n`);
253
+ }
254
+ };
255
+ process.stdout.write(`${question}\n`);
256
+ draw(true);
257
+ const input = process.stdin;
258
+ const wasRaw = input.isRaw;
259
+ input.setRawMode(true);
260
+ input.resume();
261
+ return await new Promise((resolve) => {
262
+ const done = (value) => {
263
+ input.off("data", onData);
264
+ input.setRawMode(wasRaw ?? false);
265
+ input.pause();
266
+ process.stdout.write("\n");
267
+ resolve(value);
268
+ };
269
+ const onData = (chunk) => {
270
+ const s = chunk.toString("utf8");
271
+ if (s === "\u0003") {
272
+ input.setRawMode(wasRaw ?? false);
273
+ process.exit(130);
274
+ }
275
+ if (s === "\r" || s === "\n") return done(items[idx]);
276
+ if (s === "\x1b[A" || s === "k") {
277
+ idx = (idx - 1 + items.length) % items.length;
278
+ return draw(false);
279
+ }
280
+ if (s === "\x1b[B" || s === "j") {
281
+ idx = (idx + 1) % items.length;
282
+ return draw(false);
283
+ }
284
+ const n = Number(s);
285
+ if (Number.isInteger(n) && n >= 1 && n <= items.length) {
286
+ idx = n - 1;
287
+ draw(false);
288
+ }
289
+ };
290
+ input.on("data", onData);
291
+ });
292
+ }
293
+
294
+ /**
295
+ * After login, pick a workspace (auto if there is only one, otherwise prompt)
296
+ * and remember a single source database when the workspace has exactly one.
297
+ */
298
+ async function settleContext(cfg, api) {
299
+ try {
300
+ const v = await api.get("/api/workspaces");
301
+ const list = v?.workspaces ?? [];
302
+ let ws = cfg.workspace_id;
303
+ if (!ws) {
304
+ if (list.length === 1) {
305
+ ws = list[0].id;
306
+ console.log(`using workspace ${ws} (${list[0].name})`);
307
+ } else if (list.length > 1) {
308
+ const picked = await chooseRadio("Select a workspace:", list, (w) => `${w.name} (${w.role})`);
309
+ if (picked) {
310
+ ws = picked.id;
311
+ console.log(`using workspace ${ws} (${picked.name})`);
312
+ }
313
+ }
314
+ if (ws) {
315
+ cfg.workspace_id = ws;
316
+ saveConfig(cfg);
317
+ }
318
+ }
319
+ if (!ws || cfg.source_db) return;
320
+ const sds = (await workspaceDetail(api, ws))?.source_dbs ?? [];
321
+ if (sds.length === 1) {
322
+ cfg.source_db = sds[0].name;
323
+ saveConfig(cfg);
324
+ console.log(`using source database "${sds[0].name}"`);
325
+ }
326
+ } catch {
327
+ /* best effort — never fail a login over this */
328
+ }
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Commands
333
+ // ---------------------------------------------------------------------------
334
+
335
+ async function cmdAuth(cfg, args) {
336
+ const base = cfg.api_url;
337
+ const token = str(args.flags.token);
338
+ if (token) {
339
+ cfg.token = token;
340
+ cfg.cookie = null;
341
+ saveConfig(cfg);
342
+ console.log(`stored API token for ${base}`);
343
+ await settleContext(cfg, apiFor(cfg));
344
+ return;
345
+ }
346
+ const password = str(args.flags.password);
347
+ if (password === undefined) {
348
+ // Browser device flow.
349
+ const anon = new Api(base);
350
+ const start = await anon.post("/api/auth/device/start");
351
+ const device = start?.device_code ?? "";
352
+ const code = start?.user_code ?? "";
353
+ const uri = start?.verification_uri ?? base;
354
+ if (!device) throw new Error("device flow unavailable on this server");
355
+ console.log(`Open ${uri} and enter the code: ${code}`);
356
+ for (let i = 0; i < 100; i++) {
357
+ await sleep(3000);
358
+ const r = await anon.post("/api/auth/device/poll", { device_code: device });
359
+ if (r?.status === "approved") {
360
+ cfg.token = r.secret;
361
+ cfg.cookie = null;
362
+ saveConfig(cfg);
363
+ console.log("logged in — token stored");
364
+ await settleContext(cfg, apiFor(cfg));
365
+ return;
366
+ }
367
+ if (r?.status !== "pending") throw new Error(`device login failed: ${JSON.stringify(r)}`);
368
+ }
369
+ throw new Error("device code expired — run `dbd auth` again");
370
+ }
371
+ const username = str(args.flags.username) ?? cfg.username;
372
+ if (!username) throw new Error("--username is required for password login");
373
+ const cookie = await login(base, username, password);
374
+ cfg.cookie = cookie;
375
+ cfg.token = null;
376
+ cfg.username = username;
377
+ cfg.password = password;
378
+ saveConfig(cfg);
379
+ console.log(`logged in as ${username} (${base})`);
380
+ await settleContext(cfg, apiFor(cfg));
381
+ }
382
+
383
+ async function cmdWhoami(cfg) {
384
+ const v = await apiFor(cfg).get("/api/me");
385
+ const u = v?.user ?? {};
386
+ console.log(
387
+ `${u.username ?? "?"} <${u.email ?? "?"}> plan=${u.plan ?? "?"} super=${u.is_super ?? false}`
388
+ );
389
+ }
390
+
391
+ async function cmdWorkspace(cfg, args) {
392
+ const api = apiFor(cfg);
393
+ const sub = args.pos[0];
394
+ if (sub === "ls" || sub === undefined) {
395
+ const v = await api.get("/api/workspaces");
396
+ for (const w of v?.workspaces ?? []) {
397
+ const mark = w.id === cfg.workspace_id ? "*" : " ";
398
+ console.log(`${mark} ${String(w.id).padStart(3)} ${(w.name ?? "").padEnd(28)} ${w.role ?? ""}`);
399
+ }
400
+ return;
401
+ }
402
+ if (sub === "use") {
403
+ const id = Number(args.pos[1]);
404
+ if (!Number.isFinite(id)) throw new Error("usage: dbd workspace use <id>");
405
+ cfg.workspace_id = id;
406
+ saveConfig(cfg);
407
+ console.log(`current workspace: ${id}`);
408
+ return;
409
+ }
410
+ throw new Error("usage: dbd workspace ls | use <id>");
411
+ }
412
+
413
+ /** `dbd source-db …` and the `dbd add-source` / `dbd use` aliases. */
414
+ async function cmdSourceDb(cfg, args, verb) {
415
+ const api = apiFor(cfg);
416
+ const ws = await resolveWorkspace(cfg, api);
417
+ const sub = verb ?? args.pos[0];
418
+
419
+ if (sub === "ls" || sub === undefined) {
420
+ const v = await workspaceDetail(api, ws);
421
+ for (const s of v?.source_dbs ?? []) {
422
+ const n = (s.branches ?? []).length;
423
+ console.log(`${(s.name ?? "").padEnd(28)} ${n} branch(es) ${s.conn ?? ""}`);
424
+ }
425
+ return;
426
+ }
427
+ if (sub === "add") {
428
+ // `dbd add-source <name> <url>` or `dbd source-db add <name> <url>`
429
+ const name = verb ? args.pos[0] : args.pos[1];
430
+ const url = verb ? args.pos[1] : args.pos[2];
431
+ await api.post(`/api/workspaces/${ws}/source-dbs`, { name, url });
432
+ cfg.source_db = name;
433
+ saveConfig(cfg);
434
+ console.log(`source database "${name}" added (with a \`main\` branch)`);
435
+ return;
436
+ }
437
+ if (sub === "rm") {
438
+ const name = verb ? args.pos[0] : args.pos[1];
439
+ await api.del(`/api/workspaces/${ws}/source-dbs/${enc(name)}`);
440
+ console.log(`source database "${name}" deleted`);
441
+ return;
442
+ }
443
+ if (sub === "reset") {
444
+ const name = verb ? args.pos[0] : args.pos[1];
445
+ await api.post(`/api/workspaces/${ws}/source-dbs/${enc(name)}/reset`);
446
+ console.log(`reset the \`main\` overlay of "${name}"`);
447
+ return;
448
+ }
449
+ throw new Error("usage: dbd source-db ls | add <name> <url> | rm <name> | reset <name>");
450
+ }
451
+
452
+ /** `dbd use <source-db>` — set the default source database. */
453
+ async function cmdUse(cfg, args) {
454
+ const api = apiFor(cfg);
455
+ const ws = await resolveWorkspace(cfg, api);
456
+ let name = args.pos[0];
457
+ if (!name) {
458
+ const sds = (await workspaceDetail(api, ws))?.source_dbs ?? [];
459
+ if (sds.length === 1) name = sds[0].name;
460
+ else if (sds.length > 1) {
461
+ name = (await chooseRadio("Select a source database:", sds, (s) => s.name))?.name;
462
+ }
463
+ }
464
+ if (!name) throw new Error("usage: dbd use <source-db>");
465
+ await findSourceDb(api, ws, name); // validate
466
+ cfg.source_db = name;
467
+ saveConfig(cfg);
468
+ console.log(`current source database: ${name}`);
469
+ }
470
+
471
+ async function cmdBranch(cfg, args) {
472
+ const api = apiFor(cfg);
473
+ const ws = await resolveWorkspace(cfg, api);
474
+ const sub = args.pos[0];
475
+
476
+ // `dbd branch ls`
477
+ if (sub === "ls") {
478
+ const src = await resolveSourceDb(cfg, api, ws, str(args.flags.source));
479
+ const sd = await findSourceDb(api, ws, src);
480
+ for (const b of sd.branches ?? []) {
481
+ console.log(
482
+ `${(b.name ?? "").padEnd(28)} ${(b.protected ? "default" : "branch").padEnd(8)} ${b.conn ?? ""}`
483
+ );
484
+ }
485
+ return;
486
+ }
487
+ // `dbd branch rm <name>`
488
+ if (sub === "rm") {
489
+ const src = await resolveSourceDb(cfg, api, ws, str(args.flags.source));
490
+ await api.del(`/api/workspaces/${ws}/source-dbs/${enc(src)}/branches/${enc(args.pos[1])}`);
491
+ console.log(`branch "${args.pos[1]}" deleted`);
492
+ return;
493
+ }
494
+ // `dbd branch create <name>` or simply `dbd branch <name>` → print the DSN.
495
+ const explicitCreate = sub === "create";
496
+ const name = explicitCreate ? args.pos[1] : args.pos[0];
497
+ if (!name) throw new Error("usage: dbd branch <name> (or: dbd branch ls | rm <name>)");
498
+ const src = await resolveSourceDb(cfg, api, ws, str(args.flags.source));
499
+ const body = { name };
500
+ const ttl = num(args.flags["ttl-days"]);
501
+ if (ttl !== undefined) body.ttl_days = ttl;
502
+ await api.post(`/api/workspaces/${ws}/source-dbs/${enc(src)}/branches`, body);
503
+ console.log(await connFor(api, ws, src, name));
504
+ }
505
+
506
+ async function cmdToken(cfg, args) {
507
+ const api = apiFor(cfg);
508
+ const sub = args.pos[0];
509
+ if (sub === "ls" || sub === undefined) {
510
+ const v = await api.get("/api/tokens");
511
+ for (const t of v?.tokens ?? []) {
512
+ console.log(
513
+ `${String(t.id).padStart(3)} ${(t.name ?? "").padEnd(24)} [${(t.scopes ?? []).join(",")}] ws=${t.workspace_id ?? "-"} expires=${t.expires_at ?? "never"}`
514
+ );
515
+ }
516
+ return;
517
+ }
518
+ if (sub === "create") {
519
+ const scopes = (str(args.flags.scopes) ?? "read").split(",").map((s) => s.trim());
520
+ const body = { name: args.pos[1], scopes };
521
+ const wsId = num(args.flags["workspace-id"]);
522
+ if (wsId !== undefined) body.workspace_id = wsId;
523
+ const days = num(args.flags["expires-in-days"]);
524
+ if (days !== undefined) body.expires_in_days = days;
525
+ const v = await api.post("/api/tokens", body);
526
+ console.log(v?.secret ?? ""); // only the secret, so it can be piped
527
+ return;
528
+ }
529
+ if (sub === "rm") {
530
+ await api.del(`/api/tokens/${Number(args.pos[1])}`);
531
+ console.log(`token ${args.pos[1]} revoked`);
532
+ return;
533
+ }
534
+ throw new Error("usage: dbd token ls | create <name> [--scopes a,b] | rm <id>");
535
+ }
536
+
537
+ /** Run SQL over the wire (lazily loads the `postgres` driver). */
538
+ async function runSql(dsn, username, password, query) {
539
+ const url = dsn.replace(`://${username}@`, `://${username}:${encodeURIComponent(password)}@`);
540
+ const { default: postgres } = await import("postgres");
541
+ const sql = postgres(url, { ssl: "require", max: 1, idle_timeout: 2, connect_timeout: 15 });
542
+ try {
543
+ const rows = await sql.unsafe(query);
544
+ if (Array.isArray(rows)) {
545
+ for (const row of rows) console.log(Object.values(row).map(String).join("\t"));
546
+ }
547
+ } finally {
548
+ await sql.end({ timeout: 2 });
549
+ }
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // main
554
+ // ---------------------------------------------------------------------------
555
+
556
+ function usage() {
557
+ console.log(`dbd ${VERSION} — db_drifter CLI
558
+
559
+ Usage: dbd [--api-url <url>] <command> [args]
560
+
561
+ auth [--username U] [--password P] [--token T] log in (device flow by default)
562
+ whoami
563
+ use <source-db> set the default source database
564
+
565
+ add-source <name> <url> connect a source database (admin)
566
+ source-db ls | add <name> <url> | rm <name> | reset <name>
567
+
568
+ branch <name> [--source S] [--ttl-days N] create a branch, print its DSN
569
+ branch ls | rm <name>
570
+
571
+ conn <source-db> [--branch <name>] print a connection string
572
+ sql <source-db> [--branch <name>] <query> run SQL over the wire
573
+ workspace ls | use <id>
574
+ token ls | create <name> [--scopes a,b] | rm <id>
575
+
576
+ Environment: DBDRIFTER_API_URL, DBDRIFTER_TOKEN
577
+ Config: ~/.config/db_drifter/config.json (0600)`);
578
+ }
579
+
580
+ async function main() {
581
+ const args = parseArgs(process.argv.slice(2));
582
+ const cfg = loadConfig();
583
+ const apiUrl = str(args.flags["api-url"]) ?? process.env.DBDRIFTER_API_URL ?? cfg.api_url;
584
+ cfg.api_url = apiUrl.replace(/\/$/, "");
585
+
586
+ if (args.pos.length === 0 || args.flags.help || args.flags.h) {
587
+ usage();
588
+ return;
589
+ }
590
+
591
+ const [cmd] = args.pos;
592
+ const rest = { pos: args.pos.slice(1), flags: args.flags };
593
+ const api = apiFor(cfg);
594
+
595
+ switch (cmd) {
596
+ case "auth":
597
+ case "login":
598
+ await cmdAuth(cfg, args);
599
+ break;
600
+ case "logout":
601
+ cfg.cookie = null;
602
+ cfg.token = null;
603
+ cfg.password = null;
604
+ saveConfig(cfg);
605
+ console.log("logged out");
606
+ break;
607
+ case "whoami":
608
+ await cmdWhoami(cfg);
609
+ break;
610
+ case "use":
611
+ await cmdUse(cfg, rest);
612
+ break;
613
+ case "workspace":
614
+ await cmdWorkspace(cfg, rest);
615
+ break;
616
+ case "add-source":
617
+ await cmdSourceDb(cfg, rest, "add");
618
+ break;
619
+ case "source-db":
620
+ case "source":
621
+ await cmdSourceDb(cfg, rest);
622
+ break;
623
+ case "branch":
624
+ await cmdBranch(cfg, rest);
625
+ break;
626
+ case "token":
627
+ await cmdToken(cfg, rest);
628
+ break;
629
+ case "conn": {
630
+ const ws = await resolveWorkspace(cfg, api);
631
+ console.log(await connFor(api, ws, rest.pos[0], str(args.flags.branch)));
632
+ break;
633
+ }
634
+ case "sql": {
635
+ const ws = await resolveWorkspace(cfg, api);
636
+ const dsn = await connFor(api, ws, rest.pos[0], str(args.flags.branch));
637
+ if (!cfg.username || !cfg.password) throw new Error("not logged in; run `dbd auth`");
638
+ const query = rest.pos.slice(1).join(" ");
639
+ if (!query.trim()) throw new Error("no SQL provided");
640
+ await runSql(dsn, cfg.username, cfg.password, query);
641
+ break;
642
+ }
643
+ default:
644
+ usage();
645
+ process.exitCode = 2;
646
+ }
647
+ }
648
+
649
+ main().catch((err) => {
650
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
651
+ process.exitCode = 1;
652
+ });