db-drifter-cli 0.0.0-stage → 0.3.1
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.
- package/README.md +130 -2
- package/package.json +35 -5
- package/src/index.js +655 -0
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dbd — db_drifter CLI
|
|
2
2
|
|
|
3
|
-
|
|
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 DB (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 DB.
|
|
32
|
+
`dbd branch <name>` creates an isolated overlay on that source DB and prints its
|
|
33
|
+
connection string — hand it straight to your app, tests or an agent. Run
|
|
34
|
+
`dbd add-source-db <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 DB 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 DB
|
|
50
|
+
|
|
51
|
+
add-source-db <name> <url> connect a source DB (admin)
|
|
52
|
+
source-db ls | add <name> <url> | rm <name> | reset <name>
|
|
53
|
+
|
|
54
|
+
branch <name> [--source-db 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 DBs (scope `admin`) — CI can provision its own database without being able
|
|
99
|
+
to widen access.
|
|
100
|
+
|
|
101
|
+
## Releasing
|
|
102
|
+
|
|
103
|
+
The package lives in `cli/` and is published as **`db-drifter-cli`** (it installs
|
|
104
|
+
the `dbd` command). Publishing needs your npm 2FA:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
cd cli
|
|
108
|
+
npm version patch # or minor / major — bumps package.json + tags
|
|
109
|
+
npm publish --otp=<6-digit code>
|
|
110
|
+
git push --follow-tags
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`publishConfig` pins the public registry and `access: public`, so no extra flags
|
|
114
|
+
are needed. After publishing, `latest` follows the highest version — check with:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
npm view db-drifter-cli dist-tags
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Sanity-check the tarball before releasing (it should include `src/index.js`):
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
npm pack --dry-run
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
If you ever need to publish from a repo checkout before npm has it:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
npm i -g ./cli # installs `dbd` from the local folder
|
|
130
|
+
node ./cli/src/index.js --help
|
|
131
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
2
|
+
"name": "db-drifter-cli",
|
|
3
|
+
"version": "0.3.1",
|
|
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,655 @@
|
|
|
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.1";
|
|
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 DB "${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 DB yet; run `dbd add-source-db <name> <url>`");
|
|
152
|
+
const sds = v.source_dbs;
|
|
153
|
+
const picked = await chooseRadio("Select a source DB:", 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 DB? pass --source-db <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
|
+
/** The source DB flag: `--source-db` (with `--source` accepted for back-compat). */
|
|
173
|
+
const sourceFlag = (args) => str(args.flags["source-db"]) ?? sourceFlag(args);
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Args
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function parseArgs(argv) {
|
|
180
|
+
const out = { pos: [], flags: {} };
|
|
181
|
+
for (let i = 0; i < argv.length; i++) {
|
|
182
|
+
const a = argv[i];
|
|
183
|
+
if (a.startsWith("--")) {
|
|
184
|
+
const eq = a.indexOf("=");
|
|
185
|
+
if (eq > -1) out.flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
186
|
+
else {
|
|
187
|
+
const key = a.slice(2);
|
|
188
|
+
const next = argv[i + 1];
|
|
189
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
190
|
+
out.flags[key] = next;
|
|
191
|
+
i++;
|
|
192
|
+
} else out.flags[key] = true;
|
|
193
|
+
}
|
|
194
|
+
} else out.pos.push(a);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
200
|
+
const num = (v) => {
|
|
201
|
+
const s = str(v);
|
|
202
|
+
if (s === undefined) return undefined;
|
|
203
|
+
const n = Number(s);
|
|
204
|
+
return Number.isFinite(n) ? n : undefined;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Hidden password prompt
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async function promptPassword(prompt) {
|
|
212
|
+
process.stdout.write(prompt);
|
|
213
|
+
const input = process.stdin;
|
|
214
|
+
const canRaw = typeof input.setRawMode === "function";
|
|
215
|
+
if (canRaw) input.setRawMode(true);
|
|
216
|
+
input.resume();
|
|
217
|
+
return await new Promise((resolve) => {
|
|
218
|
+
let buf = "";
|
|
219
|
+
const onData = (chunk) => {
|
|
220
|
+
for (const ch of chunk.toString("utf8")) {
|
|
221
|
+
if (ch === "\r" || ch === "\n") {
|
|
222
|
+
input.off("data", onData);
|
|
223
|
+
if (canRaw) input.setRawMode(false);
|
|
224
|
+
input.pause();
|
|
225
|
+
process.stdout.write("\n");
|
|
226
|
+
resolve(buf);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (ch === "\u0003") process.exit(130);
|
|
230
|
+
if (ch === "\u007f") buf = buf.slice(0, -1);
|
|
231
|
+
else buf += ch;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
input.on("data", onData);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Interactive selection
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const isTTY = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Radio-style selector: ↑/↓ (or j/k, or a number) then Enter. Returns the
|
|
246
|
+
* chosen item, or null when there is no TTY to prompt on.
|
|
247
|
+
*/
|
|
248
|
+
async function chooseRadio(question, items, label) {
|
|
249
|
+
if (!isTTY() || items.length === 0) return null;
|
|
250
|
+
let idx = 0;
|
|
251
|
+
const draw = (first) => {
|
|
252
|
+
if (!first) process.stdout.write(`\x1b[${items.length}A`);
|
|
253
|
+
for (let i = 0; i < items.length; i++) {
|
|
254
|
+
const mark = i === idx ? "❯" : " ";
|
|
255
|
+
process.stdout.write(`\x1b[K ${mark} ${label(items[i], i)}\n`);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
process.stdout.write(`${question}\n`);
|
|
259
|
+
draw(true);
|
|
260
|
+
const input = process.stdin;
|
|
261
|
+
const wasRaw = input.isRaw;
|
|
262
|
+
input.setRawMode(true);
|
|
263
|
+
input.resume();
|
|
264
|
+
return await new Promise((resolve) => {
|
|
265
|
+
const done = (value) => {
|
|
266
|
+
input.off("data", onData);
|
|
267
|
+
input.setRawMode(wasRaw ?? false);
|
|
268
|
+
input.pause();
|
|
269
|
+
process.stdout.write("\n");
|
|
270
|
+
resolve(value);
|
|
271
|
+
};
|
|
272
|
+
const onData = (chunk) => {
|
|
273
|
+
const s = chunk.toString("utf8");
|
|
274
|
+
if (s === "\u0003") {
|
|
275
|
+
input.setRawMode(wasRaw ?? false);
|
|
276
|
+
process.exit(130);
|
|
277
|
+
}
|
|
278
|
+
if (s === "\r" || s === "\n") return done(items[idx]);
|
|
279
|
+
if (s === "\x1b[A" || s === "k") {
|
|
280
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
281
|
+
return draw(false);
|
|
282
|
+
}
|
|
283
|
+
if (s === "\x1b[B" || s === "j") {
|
|
284
|
+
idx = (idx + 1) % items.length;
|
|
285
|
+
return draw(false);
|
|
286
|
+
}
|
|
287
|
+
const n = Number(s);
|
|
288
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) {
|
|
289
|
+
idx = n - 1;
|
|
290
|
+
draw(false);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
input.on("data", onData);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* After login, pick a workspace (auto if there is only one, otherwise prompt)
|
|
299
|
+
* and remember a single source DB when the workspace has exactly one.
|
|
300
|
+
*/
|
|
301
|
+
async function settleContext(cfg, api) {
|
|
302
|
+
try {
|
|
303
|
+
const v = await api.get("/api/workspaces");
|
|
304
|
+
const list = v?.workspaces ?? [];
|
|
305
|
+
let ws = cfg.workspace_id;
|
|
306
|
+
if (!ws) {
|
|
307
|
+
if (list.length === 1) {
|
|
308
|
+
ws = list[0].id;
|
|
309
|
+
console.log(`using workspace ${ws} (${list[0].name})`);
|
|
310
|
+
} else if (list.length > 1) {
|
|
311
|
+
const picked = await chooseRadio("Select a workspace:", list, (w) => `${w.name} (${w.role})`);
|
|
312
|
+
if (picked) {
|
|
313
|
+
ws = picked.id;
|
|
314
|
+
console.log(`using workspace ${ws} (${picked.name})`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (ws) {
|
|
318
|
+
cfg.workspace_id = ws;
|
|
319
|
+
saveConfig(cfg);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!ws || cfg.source_db) return;
|
|
323
|
+
const sds = (await workspaceDetail(api, ws))?.source_dbs ?? [];
|
|
324
|
+
if (sds.length === 1) {
|
|
325
|
+
cfg.source_db = sds[0].name;
|
|
326
|
+
saveConfig(cfg);
|
|
327
|
+
console.log(`using source DB "${sds[0].name}"`);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
/* best effort — never fail a login over this */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Commands
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
async function cmdAuth(cfg, args) {
|
|
339
|
+
const base = cfg.api_url;
|
|
340
|
+
const token = str(args.flags.token);
|
|
341
|
+
if (token) {
|
|
342
|
+
cfg.token = token;
|
|
343
|
+
cfg.cookie = null;
|
|
344
|
+
saveConfig(cfg);
|
|
345
|
+
console.log(`stored API token for ${base}`);
|
|
346
|
+
await settleContext(cfg, apiFor(cfg));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const password = str(args.flags.password);
|
|
350
|
+
if (password === undefined) {
|
|
351
|
+
// Browser device flow.
|
|
352
|
+
const anon = new Api(base);
|
|
353
|
+
const start = await anon.post("/api/auth/device/start");
|
|
354
|
+
const device = start?.device_code ?? "";
|
|
355
|
+
const code = start?.user_code ?? "";
|
|
356
|
+
const uri = start?.verification_uri ?? base;
|
|
357
|
+
if (!device) throw new Error("device flow unavailable on this server");
|
|
358
|
+
console.log(`Open ${uri} and enter the code: ${code}`);
|
|
359
|
+
for (let i = 0; i < 100; i++) {
|
|
360
|
+
await sleep(3000);
|
|
361
|
+
const r = await anon.post("/api/auth/device/poll", { device_code: device });
|
|
362
|
+
if (r?.status === "approved") {
|
|
363
|
+
cfg.token = r.secret;
|
|
364
|
+
cfg.cookie = null;
|
|
365
|
+
saveConfig(cfg);
|
|
366
|
+
console.log("logged in — token stored");
|
|
367
|
+
await settleContext(cfg, apiFor(cfg));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (r?.status !== "pending") throw new Error(`device login failed: ${JSON.stringify(r)}`);
|
|
371
|
+
}
|
|
372
|
+
throw new Error("device code expired — run `dbd auth` again");
|
|
373
|
+
}
|
|
374
|
+
const username = str(args.flags.username) ?? cfg.username;
|
|
375
|
+
if (!username) throw new Error("--username is required for password login");
|
|
376
|
+
const cookie = await login(base, username, password);
|
|
377
|
+
cfg.cookie = cookie;
|
|
378
|
+
cfg.token = null;
|
|
379
|
+
cfg.username = username;
|
|
380
|
+
cfg.password = password;
|
|
381
|
+
saveConfig(cfg);
|
|
382
|
+
console.log(`logged in as ${username} (${base})`);
|
|
383
|
+
await settleContext(cfg, apiFor(cfg));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function cmdWhoami(cfg) {
|
|
387
|
+
const v = await apiFor(cfg).get("/api/me");
|
|
388
|
+
const u = v?.user ?? {};
|
|
389
|
+
console.log(
|
|
390
|
+
`${u.username ?? "?"} <${u.email ?? "?"}> plan=${u.plan ?? "?"} super=${u.is_super ?? false}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cmdWorkspace(cfg, args) {
|
|
395
|
+
const api = apiFor(cfg);
|
|
396
|
+
const sub = args.pos[0];
|
|
397
|
+
if (sub === "ls" || sub === undefined) {
|
|
398
|
+
const v = await api.get("/api/workspaces");
|
|
399
|
+
for (const w of v?.workspaces ?? []) {
|
|
400
|
+
const mark = w.id === cfg.workspace_id ? "*" : " ";
|
|
401
|
+
console.log(`${mark} ${String(w.id).padStart(3)} ${(w.name ?? "").padEnd(28)} ${w.role ?? ""}`);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (sub === "use") {
|
|
406
|
+
const id = Number(args.pos[1]);
|
|
407
|
+
if (!Number.isFinite(id)) throw new Error("usage: dbd workspace use <id>");
|
|
408
|
+
cfg.workspace_id = id;
|
|
409
|
+
saveConfig(cfg);
|
|
410
|
+
console.log(`current workspace: ${id}`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
throw new Error("usage: dbd workspace ls | use <id>");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** `dbd source-db …` and the `dbd add-source-db` / `dbd use` aliases. */
|
|
417
|
+
async function cmdSourceDb(cfg, args, verb) {
|
|
418
|
+
const api = apiFor(cfg);
|
|
419
|
+
const ws = await resolveWorkspace(cfg, api);
|
|
420
|
+
const sub = verb ?? args.pos[0];
|
|
421
|
+
|
|
422
|
+
if (sub === "ls" || sub === undefined) {
|
|
423
|
+
const v = await workspaceDetail(api, ws);
|
|
424
|
+
for (const s of v?.source_dbs ?? []) {
|
|
425
|
+
const n = (s.branches ?? []).length;
|
|
426
|
+
console.log(`${(s.name ?? "").padEnd(28)} ${n} branch(es) ${s.conn ?? ""}`);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (sub === "add") {
|
|
431
|
+
// `dbd add-source-db <name> <url>` or `dbd source-db add <name> <url>`
|
|
432
|
+
const name = verb ? args.pos[0] : args.pos[1];
|
|
433
|
+
const url = verb ? args.pos[1] : args.pos[2];
|
|
434
|
+
await api.post(`/api/workspaces/${ws}/source-dbs`, { name, url });
|
|
435
|
+
cfg.source_db = name;
|
|
436
|
+
saveConfig(cfg);
|
|
437
|
+
console.log(`source DB "${name}" added (with a \`main\` branch)`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (sub === "rm") {
|
|
441
|
+
const name = verb ? args.pos[0] : args.pos[1];
|
|
442
|
+
await api.del(`/api/workspaces/${ws}/source-dbs/${enc(name)}`);
|
|
443
|
+
console.log(`source DB "${name}" deleted`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (sub === "reset") {
|
|
447
|
+
const name = verb ? args.pos[0] : args.pos[1];
|
|
448
|
+
await api.post(`/api/workspaces/${ws}/source-dbs/${enc(name)}/reset`);
|
|
449
|
+
console.log(`reset the \`main\` overlay of "${name}"`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
throw new Error("usage: dbd source-db ls | add <name> <url> | rm <name> | reset <name>");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** `dbd use <source-db>` — set the default source DB. */
|
|
456
|
+
async function cmdUse(cfg, args) {
|
|
457
|
+
const api = apiFor(cfg);
|
|
458
|
+
const ws = await resolveWorkspace(cfg, api);
|
|
459
|
+
let name = args.pos[0];
|
|
460
|
+
if (!name) {
|
|
461
|
+
const sds = (await workspaceDetail(api, ws))?.source_dbs ?? [];
|
|
462
|
+
if (sds.length === 1) name = sds[0].name;
|
|
463
|
+
else if (sds.length > 1) {
|
|
464
|
+
name = (await chooseRadio("Select a source DB:", sds, (s) => s.name))?.name;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!name) throw new Error("usage: dbd use <source-db>");
|
|
468
|
+
await findSourceDb(api, ws, name); // validate
|
|
469
|
+
cfg.source_db = name;
|
|
470
|
+
saveConfig(cfg);
|
|
471
|
+
console.log(`current source DB: ${name}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function cmdBranch(cfg, args) {
|
|
475
|
+
const api = apiFor(cfg);
|
|
476
|
+
const ws = await resolveWorkspace(cfg, api);
|
|
477
|
+
const sub = args.pos[0];
|
|
478
|
+
|
|
479
|
+
// `dbd branch ls`
|
|
480
|
+
if (sub === "ls") {
|
|
481
|
+
const src = await resolveSourceDb(cfg, api, ws, sourceFlag(args));
|
|
482
|
+
const sd = await findSourceDb(api, ws, src);
|
|
483
|
+
for (const b of sd.branches ?? []) {
|
|
484
|
+
console.log(
|
|
485
|
+
`${(b.name ?? "").padEnd(28)} ${(b.protected ? "default" : "branch").padEnd(8)} ${b.conn ?? ""}`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// `dbd branch rm <name>`
|
|
491
|
+
if (sub === "rm") {
|
|
492
|
+
const src = await resolveSourceDb(cfg, api, ws, sourceFlag(args));
|
|
493
|
+
await api.del(`/api/workspaces/${ws}/source-dbs/${enc(src)}/branches/${enc(args.pos[1])}`);
|
|
494
|
+
console.log(`branch "${args.pos[1]}" deleted`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// `dbd branch create <name>` or simply `dbd branch <name>` → print the DSN.
|
|
498
|
+
const explicitCreate = sub === "create";
|
|
499
|
+
const name = explicitCreate ? args.pos[1] : args.pos[0];
|
|
500
|
+
if (!name) throw new Error("usage: dbd branch <name> (or: dbd branch ls | rm <name>)");
|
|
501
|
+
const src = await resolveSourceDb(cfg, api, ws, sourceFlag(args));
|
|
502
|
+
const body = { name };
|
|
503
|
+
const ttl = num(args.flags["ttl-days"]);
|
|
504
|
+
if (ttl !== undefined) body.ttl_days = ttl;
|
|
505
|
+
await api.post(`/api/workspaces/${ws}/source-dbs/${enc(src)}/branches`, body);
|
|
506
|
+
console.log(await connFor(api, ws, src, name));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function cmdToken(cfg, args) {
|
|
510
|
+
const api = apiFor(cfg);
|
|
511
|
+
const sub = args.pos[0];
|
|
512
|
+
if (sub === "ls" || sub === undefined) {
|
|
513
|
+
const v = await api.get("/api/tokens");
|
|
514
|
+
for (const t of v?.tokens ?? []) {
|
|
515
|
+
console.log(
|
|
516
|
+
`${String(t.id).padStart(3)} ${(t.name ?? "").padEnd(24)} [${(t.scopes ?? []).join(",")}] ws=${t.workspace_id ?? "-"} expires=${t.expires_at ?? "never"}`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (sub === "create") {
|
|
522
|
+
const scopes = (str(args.flags.scopes) ?? "read").split(",").map((s) => s.trim());
|
|
523
|
+
const body = { name: args.pos[1], scopes };
|
|
524
|
+
const wsId = num(args.flags["workspace-id"]);
|
|
525
|
+
if (wsId !== undefined) body.workspace_id = wsId;
|
|
526
|
+
const days = num(args.flags["expires-in-days"]);
|
|
527
|
+
if (days !== undefined) body.expires_in_days = days;
|
|
528
|
+
const v = await api.post("/api/tokens", body);
|
|
529
|
+
console.log(v?.secret ?? ""); // only the secret, so it can be piped
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (sub === "rm") {
|
|
533
|
+
await api.del(`/api/tokens/${Number(args.pos[1])}`);
|
|
534
|
+
console.log(`token ${args.pos[1]} revoked`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
throw new Error("usage: dbd token ls | create <name> [--scopes a,b] | rm <id>");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Run SQL over the wire (lazily loads the `postgres` driver). */
|
|
541
|
+
async function runSql(dsn, username, password, query) {
|
|
542
|
+
const url = dsn.replace(`://${username}@`, `://${username}:${encodeURIComponent(password)}@`);
|
|
543
|
+
const { default: postgres } = await import("postgres");
|
|
544
|
+
const sql = postgres(url, { ssl: "require", max: 1, idle_timeout: 2, connect_timeout: 15 });
|
|
545
|
+
try {
|
|
546
|
+
const rows = await sql.unsafe(query);
|
|
547
|
+
if (Array.isArray(rows)) {
|
|
548
|
+
for (const row of rows) console.log(Object.values(row).map(String).join("\t"));
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
551
|
+
await sql.end({ timeout: 2 });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// main
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
function usage() {
|
|
560
|
+
console.log(`dbd ${VERSION} — db_drifter CLI
|
|
561
|
+
|
|
562
|
+
Usage: dbd [--api-url <url>] <command> [args]
|
|
563
|
+
|
|
564
|
+
auth [--username U] [--password P] [--token T] log in (device flow by default)
|
|
565
|
+
whoami
|
|
566
|
+
use <source-db> set the default source DB
|
|
567
|
+
|
|
568
|
+
add-source-db <name> <url> connect a source DB (admin)
|
|
569
|
+
source-db ls | add <name> <url> | rm <name> | reset <name>
|
|
570
|
+
|
|
571
|
+
branch <name> [--source-db S] [--ttl-days N] create a branch, print its DSN
|
|
572
|
+
branch ls | rm <name>
|
|
573
|
+
|
|
574
|
+
conn <source-db> [--branch <name>] print a connection string
|
|
575
|
+
sql <source-db> [--branch <name>] <query> run SQL over the wire
|
|
576
|
+
workspace ls | use <id>
|
|
577
|
+
token ls | create <name> [--scopes a,b] | rm <id>
|
|
578
|
+
|
|
579
|
+
Environment: DBDRIFTER_API_URL, DBDRIFTER_TOKEN
|
|
580
|
+
Config: ~/.config/db_drifter/config.json (0600)`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function main() {
|
|
584
|
+
const args = parseArgs(process.argv.slice(2));
|
|
585
|
+
const cfg = loadConfig();
|
|
586
|
+
const apiUrl = str(args.flags["api-url"]) ?? process.env.DBDRIFTER_API_URL ?? cfg.api_url;
|
|
587
|
+
cfg.api_url = apiUrl.replace(/\/$/, "");
|
|
588
|
+
|
|
589
|
+
if (args.pos.length === 0 || args.flags.help || args.flags.h) {
|
|
590
|
+
usage();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const [cmd] = args.pos;
|
|
595
|
+
const rest = { pos: args.pos.slice(1), flags: args.flags };
|
|
596
|
+
const api = apiFor(cfg);
|
|
597
|
+
|
|
598
|
+
switch (cmd) {
|
|
599
|
+
case "auth":
|
|
600
|
+
case "login":
|
|
601
|
+
await cmdAuth(cfg, args);
|
|
602
|
+
break;
|
|
603
|
+
case "logout":
|
|
604
|
+
cfg.cookie = null;
|
|
605
|
+
cfg.token = null;
|
|
606
|
+
cfg.password = null;
|
|
607
|
+
saveConfig(cfg);
|
|
608
|
+
console.log("logged out");
|
|
609
|
+
break;
|
|
610
|
+
case "whoami":
|
|
611
|
+
await cmdWhoami(cfg);
|
|
612
|
+
break;
|
|
613
|
+
case "use":
|
|
614
|
+
await cmdUse(cfg, rest);
|
|
615
|
+
break;
|
|
616
|
+
case "workspace":
|
|
617
|
+
await cmdWorkspace(cfg, rest);
|
|
618
|
+
break;
|
|
619
|
+
case "add-source-db":
|
|
620
|
+
case "add-source":
|
|
621
|
+
await cmdSourceDb(cfg, rest, "add");
|
|
622
|
+
break;
|
|
623
|
+
case "source-db":
|
|
624
|
+
await cmdSourceDb(cfg, rest);
|
|
625
|
+
break;
|
|
626
|
+
case "branch":
|
|
627
|
+
await cmdBranch(cfg, rest);
|
|
628
|
+
break;
|
|
629
|
+
case "token":
|
|
630
|
+
await cmdToken(cfg, rest);
|
|
631
|
+
break;
|
|
632
|
+
case "conn": {
|
|
633
|
+
const ws = await resolveWorkspace(cfg, api);
|
|
634
|
+
console.log(await connFor(api, ws, rest.pos[0], str(args.flags.branch)));
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
case "sql": {
|
|
638
|
+
const ws = await resolveWorkspace(cfg, api);
|
|
639
|
+
const dsn = await connFor(api, ws, rest.pos[0], str(args.flags.branch));
|
|
640
|
+
if (!cfg.username || !cfg.password) throw new Error("not logged in; run `dbd auth`");
|
|
641
|
+
const query = rest.pos.slice(1).join(" ");
|
|
642
|
+
if (!query.trim()) throw new Error("no SQL provided");
|
|
643
|
+
await runSql(dsn, cfg.username, cfg.password, query);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
default:
|
|
647
|
+
usage();
|
|
648
|
+
process.exitCode = 2;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
main().catch((err) => {
|
|
653
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
654
|
+
process.exitCode = 1;
|
|
655
|
+
});
|