@deepsql/mcp 0.8.0 → 0.10.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.
- package/AGENT-SETUP.md +289 -0
- package/CLAUDE.md +330 -0
- package/package.json +3 -1
- package/src/auth/store.js +32 -1
- package/src/auth/store.test.js +22 -0
- package/src/cli.js +40 -5
- package/src/commands/_connections.js +26 -3
- package/src/commands/_connections.test.js +21 -4
- package/src/commands/_session.js +4 -1
- package/src/commands/access.js +0 -2
- package/src/commands/admin.test.js +37 -0
- package/src/commands/anti-patterns.js +0 -1
- package/src/commands/brain-context.js +27 -8
- package/src/commands/business-rules.js +0 -1
- package/src/commands/connections.js +559 -9
- package/src/commands/digest.js +3 -5
- package/src/commands/explain.js +0 -1
- package/src/commands/query.js +0 -1
- package/src/commands/relationships.js +0 -1
- package/src/commands/schema.js +0 -1
- package/src/commands/slow-queries.js +2 -3
- package/src/commands/whoami.js +11 -5
- package/src/connections/schema.js +213 -0
- package/src/connections/secrets.js +167 -0
- package/src/connections/secrets.test.js +151 -0
|
@@ -1,13 +1,63 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql connections` — list, pin, and (Phase 4) full CRUD over connections.
|
|
5
|
+
*
|
|
6
|
+
* list Tabular list with active default marker.
|
|
7
|
+
* use <name> Pin <name> as the active default for the profile.
|
|
8
|
+
* current Print the active default (exit 1 if none).
|
|
9
|
+
* unset Clear the active default for this profile.
|
|
10
|
+
* schema [--json] Print the JSON Schema for the input file.
|
|
11
|
+
* add [--from-file <path>] Create a connection (interactive or JSON).
|
|
12
|
+
* [--from-stdin] Read JSON from stdin instead of a file.
|
|
13
|
+
* [--upsert] PUT instead of POST if a name collision exists.
|
|
14
|
+
* [--no-test] Skip pre-save POST /connections/test.
|
|
15
|
+
* [--wait] Poll brain init to COMPLETED/FAILED.
|
|
16
|
+
* [--delete-after] rm the --from-file path on success.
|
|
17
|
+
* [--allow-plaintext-secrets]
|
|
18
|
+
* [--cloud] Prompt for cloud/instance metadata interactively.
|
|
19
|
+
* update <name> [--from-file <path>] PUT /connections/{id} with merge.
|
|
20
|
+
* remove <name> [--yes] DELETE /connections/{id}.
|
|
21
|
+
* test [<name> | --from-file <path>] POST /connections/test, no save.
|
|
22
|
+
* show <name> [--json] GET /connections/{id} with secrets masked.
|
|
23
|
+
* init <name> [--force] [--wait] POST /connections/{id}/reinit.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require("node:fs");
|
|
27
|
+
const { ApiError, request } = require("../api/client");
|
|
28
|
+
const store = require("../auth/store");
|
|
4
29
|
const { resolveSession } = require("./_session");
|
|
30
|
+
const { resolveConnectionId } = require("./_connections");
|
|
31
|
+
const { resolveSecrets, maskSecrets, SECRET_FIELDS } = require("../connections/secrets");
|
|
32
|
+
const { SCHEMA, validate } = require("../connections/schema");
|
|
33
|
+
const ui = require("../ui/prompts");
|
|
34
|
+
const { promptPassword } = require("../auth/prompt");
|
|
5
35
|
|
|
6
|
-
async function run(opts,
|
|
36
|
+
async function run(opts, io = {}) {
|
|
7
37
|
const sub = opts.positional[0] || "list";
|
|
8
|
-
|
|
9
|
-
|
|
38
|
+
switch (sub) {
|
|
39
|
+
case "list": return runList(opts, io);
|
|
40
|
+
case "use": return runUse(opts, io);
|
|
41
|
+
case "current": return runCurrent(opts, io);
|
|
42
|
+
case "unset": return runUnset(opts, io);
|
|
43
|
+
case "schema": return runSchema(opts, io);
|
|
44
|
+
case "add": return runAdd(opts, io);
|
|
45
|
+
case "update": return runUpdate(opts, io);
|
|
46
|
+
case "remove":
|
|
47
|
+
case "delete": return runRemove(opts, io);
|
|
48
|
+
case "test": return runTest(opts, io);
|
|
49
|
+
case "show": return runShow(opts, io);
|
|
50
|
+
case "init": return runInit(opts, io);
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Unknown connections subcommand: ${sub}. Try \`list\`, \`add\`, \`update\`, \`remove\`, \`test\`, \`show\`, \`init\`, \`schema\`, \`use\`, \`current\`, or \`unset\`.`,
|
|
54
|
+
);
|
|
10
55
|
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── existing list / use / current / unset (unchanged) ─────────────────────
|
|
59
|
+
|
|
60
|
+
async function runList(opts, { stdout = process.stdout } = {}) {
|
|
11
61
|
const session = resolveSession(opts);
|
|
12
62
|
const data = await request(session.baseUrl, "/connections", { token: session.token });
|
|
13
63
|
if (opts.json) {
|
|
@@ -18,8 +68,7 @@ async function run(opts, { stdout = process.stdout } = {}) {
|
|
|
18
68
|
stdout.write("No connections.\n");
|
|
19
69
|
return;
|
|
20
70
|
}
|
|
21
|
-
|
|
22
|
-
// right for back-compat / scripting.
|
|
71
|
+
const active = session.defaultConnection;
|
|
23
72
|
const rows = data.map((conn) => ({
|
|
24
73
|
name: conn.connectionName || conn.name || "(unnamed)",
|
|
25
74
|
type: conn.databaseType || conn.dbType || "?",
|
|
@@ -30,12 +79,513 @@ async function run(opts, { stdout = process.stdout } = {}) {
|
|
|
30
79
|
type: Math.max(4, ...rows.map((r) => r.type.length)),
|
|
31
80
|
};
|
|
32
81
|
stdout.write(
|
|
33
|
-
|
|
34
|
-
|
|
82
|
+
` ${"NAME".padEnd(widths.name)} ${"TYPE".padEnd(widths.type)} ID\n` +
|
|
83
|
+
` ${"-".repeat(widths.name)} ${"-".repeat(widths.type)} ${"-".repeat(36)}\n`,
|
|
35
84
|
);
|
|
36
85
|
for (const row of rows) {
|
|
37
|
-
|
|
86
|
+
const marker = active && (row.name === active || row.id === active) ? "* " : " ";
|
|
87
|
+
stdout.write(`${marker}${row.name.padEnd(widths.name)} ${row.type.padEnd(widths.type)} ${row.id}\n`);
|
|
88
|
+
}
|
|
89
|
+
if (active) {
|
|
90
|
+
stdout.write(`\n* = active default. Switch: \`deepsql connections use <name>\`.\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runUse(opts, { stdout = process.stdout } = {}) {
|
|
95
|
+
const target = opts.positional[1];
|
|
96
|
+
if (!target) throw new Error("Usage: deepsql connections use <name>");
|
|
97
|
+
const session = resolveSession(opts);
|
|
98
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
99
|
+
store.setDefaultConnection(session.baseUrl, target);
|
|
100
|
+
stdout.write(
|
|
101
|
+
`Active connection set to "${target}"${connectionId !== target ? ` (id ${connectionId})` : ""} for ${session.baseUrl}.\n`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runCurrent(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
106
|
+
const session = resolveSession(opts);
|
|
107
|
+
if (session.defaultConnection) {
|
|
108
|
+
stdout.write(`${session.defaultConnection}\n`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
stderr.write("No active connection set. Pin one with `deepsql connections use <name>`.\n");
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runUnset(opts, { stdout = process.stdout } = {}) {
|
|
116
|
+
const session = resolveSession(opts);
|
|
117
|
+
if (!session.defaultConnection) {
|
|
118
|
+
stdout.write("No active connection was set.\n");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
store.setDefaultConnection(session.baseUrl, null);
|
|
122
|
+
stdout.write(`Cleared active connection for ${session.baseUrl}.\n`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── schema ────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async function runSchema(opts, { stdout = process.stdout } = {}) {
|
|
128
|
+
if (opts.json) {
|
|
129
|
+
stdout.write(`${JSON.stringify(SCHEMA, null, 2)}\n`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Human cheat sheet: required, optional, conditional gates.
|
|
133
|
+
stdout.write(`DeepSQL connection JSON shape (v${SCHEMA.$schema.match(/draft\/([^/]+)/)?.[1] || "?"})\n\n`);
|
|
134
|
+
stdout.write(`Required:\n`);
|
|
135
|
+
for (const k of SCHEMA.required) {
|
|
136
|
+
const f = SCHEMA.properties[k];
|
|
137
|
+
stdout.write(` ${k.padEnd(20)} ${f.type}${f.enum ? ` (${f.enum.join(", ")})` : ""}\n`);
|
|
138
|
+
}
|
|
139
|
+
stdout.write(`\nSecrets ($VAR_NAME / @file:<path> supported):\n`);
|
|
140
|
+
for (const k of SECRET_FIELDS) {
|
|
141
|
+
if (SCHEMA.properties[k]) stdout.write(` ${k}\n`);
|
|
142
|
+
}
|
|
143
|
+
stdout.write(`\nSSH (if sshEnabled=true): sshHost, sshUsername, sshPort=22,\n`);
|
|
144
|
+
stdout.write(` sshAuthType=PASSWORD|PRIVATE_KEY,\n`);
|
|
145
|
+
stdout.write(` sshPassword | sshPrivateKey + sshPassphrase?\n`);
|
|
146
|
+
stdout.write(`SSL (if sslMode=server-only/server-client): sslCaCertificate,\n`);
|
|
147
|
+
stdout.write(` sslClientCertificate, sslClientKey, sslClientKeyPassphrase?\n`);
|
|
148
|
+
stdout.write(`Cloud (informational): cloudProvider, managedService, instanceClass, instanceVcpus,\n`);
|
|
149
|
+
stdout.write(` instanceMemoryGb, storageType, storageMaxIops\n\n`);
|
|
150
|
+
stdout.write(`Use \`deepsql connections schema --json\` for the full machine-readable schema.\n`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── add ───────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async function runAdd(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
156
|
+
const session = resolveSession(opts);
|
|
157
|
+
const log = (m) => stderr.write(`[deepsql] ${m}\n`);
|
|
158
|
+
|
|
159
|
+
let cfg;
|
|
160
|
+
let sourcePath = null;
|
|
161
|
+
|
|
162
|
+
if (opts.fromFile) {
|
|
163
|
+
sourcePath = opts.fromFile;
|
|
164
|
+
cfg = readJsonFile(sourcePath);
|
|
165
|
+
} else if (opts.fromStdin) {
|
|
166
|
+
cfg = JSON.parse(await readStdin());
|
|
167
|
+
} else {
|
|
168
|
+
cfg = await promptInteractive({ withCloud: !!opts.cloud });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
cfg = resolveSecrets(cfg, {
|
|
172
|
+
sourcePath,
|
|
173
|
+
allowPlaintextSecrets: !!opts.allowPlaintextSecrets,
|
|
174
|
+
log: (m) => stderr.write(`${m}\n`),
|
|
175
|
+
});
|
|
176
|
+
// Strip id if present — backend assigns it on create.
|
|
177
|
+
delete cfg.id;
|
|
178
|
+
|
|
179
|
+
const validation = validate(cfg);
|
|
180
|
+
if (!validation.ok) {
|
|
181
|
+
throw new Error(`Invalid connection config:\n ${formatErrors(validation.errors)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!opts.noTest) {
|
|
185
|
+
log("Testing connection before save…");
|
|
186
|
+
const testResult = await request(session.baseUrl, "/connections/test", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
token: session.token,
|
|
189
|
+
json: cfg,
|
|
190
|
+
timeoutMs: 60000,
|
|
191
|
+
});
|
|
192
|
+
printPrivilegeReport(stderr, testResult);
|
|
193
|
+
if (!testResult?.connectionSuccessful) {
|
|
194
|
+
throw new Error(testResult?.message || "Pre-save connection test failed.");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let saved;
|
|
199
|
+
if (opts.upsert) {
|
|
200
|
+
const existing = await findExistingByName(session, cfg.connectionName);
|
|
201
|
+
if (existing) {
|
|
202
|
+
log(`Updating existing connection "${cfg.connectionName}" (id ${existing.id})…`);
|
|
203
|
+
saved = await request(session.baseUrl, `/connections/${encodeURIComponent(existing.id)}`, {
|
|
204
|
+
method: "PUT",
|
|
205
|
+
token: session.token,
|
|
206
|
+
json: cfg,
|
|
207
|
+
timeoutMs: 60000,
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
log(`No existing connection named "${cfg.connectionName}", creating…`);
|
|
211
|
+
saved = await request(session.baseUrl, "/connections", {
|
|
212
|
+
method: "POST",
|
|
213
|
+
token: session.token,
|
|
214
|
+
json: cfg,
|
|
215
|
+
timeoutMs: 60000,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
saved = await request(session.baseUrl, "/connections", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
token: session.token,
|
|
222
|
+
json: cfg,
|
|
223
|
+
timeoutMs: 60000,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (saved && saved.success === false) {
|
|
228
|
+
throw new Error(saved.message || "Connection save failed.");
|
|
229
|
+
}
|
|
230
|
+
const id = saved?.connectionId || saved?.id;
|
|
231
|
+
if (saved?.privileges) printPrivilegeReport(stderr, saved);
|
|
232
|
+
stdout.write(`Saved "${cfg.connectionName}" (id ${id || "?"}).\n`);
|
|
233
|
+
|
|
234
|
+
if (opts.deleteAfter && opts.fromFile) {
|
|
235
|
+
try {
|
|
236
|
+
fs.unlinkSync(opts.fromFile);
|
|
237
|
+
log(`Deleted ${opts.fromFile}.`);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
log(`Could not delete ${opts.fromFile}: ${err.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (opts.wait && id) {
|
|
244
|
+
await pollInitStatus(session, id, stderr);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── update ────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
async function runUpdate(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
251
|
+
const target = opts.positional[1];
|
|
252
|
+
if (!target) throw new Error("Usage: deepsql connections update <name> --from-file <path>");
|
|
253
|
+
const session = resolveSession(opts);
|
|
254
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
255
|
+
|
|
256
|
+
let cfg;
|
|
257
|
+
if (opts.fromFile) {
|
|
258
|
+
cfg = readJsonFile(opts.fromFile);
|
|
259
|
+
} else if (opts.fromStdin) {
|
|
260
|
+
cfg = JSON.parse(await readStdin());
|
|
261
|
+
} else {
|
|
262
|
+
throw new Error("`update` requires --from-file or --from-stdin (interactive update not supported in 0.10.0).");
|
|
263
|
+
}
|
|
264
|
+
cfg = resolveSecrets(cfg, {
|
|
265
|
+
sourcePath: opts.fromFile,
|
|
266
|
+
allowPlaintextSecrets: !!opts.allowPlaintextSecrets,
|
|
267
|
+
log: (m) => stderr.write(`${m}\n`),
|
|
268
|
+
});
|
|
269
|
+
delete cfg.id;
|
|
270
|
+
|
|
271
|
+
const saved = await request(session.baseUrl, `/connections/${encodeURIComponent(connectionId)}`, {
|
|
272
|
+
method: "PUT",
|
|
273
|
+
token: session.token,
|
|
274
|
+
json: cfg,
|
|
275
|
+
timeoutMs: 60000,
|
|
276
|
+
});
|
|
277
|
+
if (saved?.success === false) {
|
|
278
|
+
throw new Error(saved.message || "Connection update failed.");
|
|
279
|
+
}
|
|
280
|
+
if (saved?.privileges) printPrivilegeReport(stderr, saved);
|
|
281
|
+
stdout.write(`Updated "${target}".\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── remove ────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
async function runRemove(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
287
|
+
const target = opts.positional[1];
|
|
288
|
+
if (!target) throw new Error("Usage: deepsql connections remove <name> [--yes]");
|
|
289
|
+
const session = resolveSession(opts);
|
|
290
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
291
|
+
|
|
292
|
+
if (!opts.yes) {
|
|
293
|
+
const ok = await ui.confirm({
|
|
294
|
+
message: `Delete connection "${target}" (id ${connectionId})? This cannot be undone.`,
|
|
295
|
+
default: false,
|
|
296
|
+
});
|
|
297
|
+
if (!ok) {
|
|
298
|
+
stderr.write("Aborted.\n");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
await request(session.baseUrl, `/connections/${encodeURIComponent(connectionId)}`, {
|
|
303
|
+
method: "DELETE",
|
|
304
|
+
token: session.token,
|
|
305
|
+
});
|
|
306
|
+
if (session.defaultConnection === target) {
|
|
307
|
+
store.setDefaultConnection(session.baseUrl, null);
|
|
308
|
+
stderr.write(`[deepsql] Cleared the active-connection pin (was "${target}").\n`);
|
|
309
|
+
}
|
|
310
|
+
stdout.write(`Deleted "${target}".\n`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── test ──────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async function runTest(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
316
|
+
const session = resolveSession(opts);
|
|
317
|
+
let cfg;
|
|
318
|
+
if (opts.fromFile || opts.fromStdin) {
|
|
319
|
+
cfg = opts.fromFile ? readJsonFile(opts.fromFile) : JSON.parse(await readStdin());
|
|
320
|
+
cfg = resolveSecrets(cfg, {
|
|
321
|
+
sourcePath: opts.fromFile,
|
|
322
|
+
allowPlaintextSecrets: !!opts.allowPlaintextSecrets,
|
|
323
|
+
log: (m) => stderr.write(`${m}\n`),
|
|
324
|
+
});
|
|
325
|
+
delete cfg.id;
|
|
326
|
+
const validation = validate(cfg);
|
|
327
|
+
if (!validation.ok) {
|
|
328
|
+
throw new Error(`Invalid connection config:\n ${formatErrors(validation.errors)}`);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
const target = opts.positional[1];
|
|
332
|
+
if (!target) {
|
|
333
|
+
throw new Error("Usage: deepsql connections test <name> | --from-file <path>");
|
|
334
|
+
}
|
|
335
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
336
|
+
cfg = await findConnectionRecord(session, connectionId);
|
|
337
|
+
if (!cfg) throw new Error(`Connection ${target} not found.`);
|
|
338
|
+
// Backend's POST /connections/test uses saved secrets when `id` is set.
|
|
339
|
+
cfg.id = connectionId;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = await request(session.baseUrl, "/connections/test", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
token: session.token,
|
|
345
|
+
json: cfg,
|
|
346
|
+
timeoutMs: 60000,
|
|
347
|
+
});
|
|
348
|
+
if (opts.json) {
|
|
349
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
printPrivilegeReport(stdout, result);
|
|
353
|
+
if (!result?.connectionSuccessful) {
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── show ──────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
async function runShow(opts, { stdout = process.stdout } = {}) {
|
|
361
|
+
const target = opts.positional[1];
|
|
362
|
+
if (!target) throw new Error("Usage: deepsql connections show <name> [--json]");
|
|
363
|
+
const session = resolveSession(opts);
|
|
364
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
365
|
+
const conn = await findConnectionRecord(session, connectionId);
|
|
366
|
+
if (!conn) throw new Error(`Connection ${target} not found.`);
|
|
367
|
+
const masked = maskSecrets(conn);
|
|
368
|
+
if (opts.json) {
|
|
369
|
+
stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
|
|
370
|
+
return;
|
|
38
371
|
}
|
|
372
|
+
for (const [key, value] of Object.entries(masked)) {
|
|
373
|
+
if (value == null || value === "") continue;
|
|
374
|
+
stdout.write(`${key.padEnd(24)} ${typeof value === "object" ? JSON.stringify(value) : value}\n`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── init ──────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
async function runInit(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
381
|
+
const target = opts.positional[1];
|
|
382
|
+
if (!target) throw new Error("Usage: deepsql connections init <name> [--force] [--wait]");
|
|
383
|
+
const session = resolveSession(opts);
|
|
384
|
+
const connectionId = await resolveConnectionId(session, target);
|
|
385
|
+
|
|
386
|
+
await request(session.baseUrl, `/connections/${encodeURIComponent(connectionId)}/reinit`, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
token: session.token,
|
|
389
|
+
json: { force: !!opts.force },
|
|
390
|
+
});
|
|
391
|
+
stdout.write(`Brain reinit triggered for "${target}".\n`);
|
|
392
|
+
if (opts.wait) await pollInitStatus(session, connectionId, stderr);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
function readJsonFile(filePath) {
|
|
398
|
+
// Mode-0600 enforcement applies when the file is the source of secrets;
|
|
399
|
+
// we honor the same env-var escape hatch the auth store uses.
|
|
400
|
+
if (process.platform !== "win32" && process.env.DEEPSQL_INSECURE_AUTH !== "1") {
|
|
401
|
+
try {
|
|
402
|
+
const stat = fs.statSync(filePath);
|
|
403
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`${filePath} has insecure permissions (${(stat.mode & 0o777).toString(8)}). ` +
|
|
406
|
+
`Run \`chmod 600 ${filePath}\` or set DEEPSQL_INSECURE_AUTH=1.`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err.code === "ENOENT") throw new Error(`File not found: ${filePath}`);
|
|
411
|
+
if (!err.message.includes("insecure permissions")) throw err;
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
throw new Error(`Could not parse JSON from ${filePath}: ${err.message}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function readStdin() {
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
let buf = "";
|
|
425
|
+
process.stdin.setEncoding("utf8");
|
|
426
|
+
process.stdin.on("data", (c) => (buf += c));
|
|
427
|
+
process.stdin.on("end", () => resolve(buf));
|
|
428
|
+
process.stdin.on("error", reject);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatErrors(errors) {
|
|
433
|
+
return errors.map((e) => `${e.path}: ${e.message}`).join("\n ");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function printPrivilegeReport(stream, result) {
|
|
437
|
+
if (!result || typeof result !== "object") return;
|
|
438
|
+
if (result.message) stream.write(`${result.message}\n`);
|
|
439
|
+
const privs = Array.isArray(result.privileges) ? result.privileges : [];
|
|
440
|
+
if (privs.length === 0) return;
|
|
441
|
+
for (const p of privs) {
|
|
442
|
+
const mark = p.granted ? "✓" : "✗";
|
|
443
|
+
const scope = p.scope ? ` [${p.scope}]` : "";
|
|
444
|
+
const reason = p.reason ? ` — ${p.reason}` : "";
|
|
445
|
+
stream.write(` ${mark} ${p.name}${scope}${reason}\n`);
|
|
446
|
+
}
|
|
447
|
+
if (Array.isArray(result.missingPrivileges) && result.missingPrivileges.length > 0) {
|
|
448
|
+
stream.write(` Missing: ${result.missingPrivileges.join(", ")}\n`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function findExistingByName(session, name) {
|
|
453
|
+
const all = await request(session.baseUrl, "/connections", { token: session.token });
|
|
454
|
+
if (!Array.isArray(all)) return null;
|
|
455
|
+
return all.find((c) => (c.connectionName || c.name) === name) || null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The backend doesn't expose `GET /connections/{id}`; the list endpoint
|
|
460
|
+
* already returns full per-connection records (without secrets). This is
|
|
461
|
+
* cheap because the list is cached briefly server-side.
|
|
462
|
+
*/
|
|
463
|
+
async function findConnectionRecord(session, connectionId) {
|
|
464
|
+
const all = await request(session.baseUrl, "/connections", { token: session.token });
|
|
465
|
+
if (!Array.isArray(all)) return null;
|
|
466
|
+
return all.find((c) => (c.id || c.connectionId) === connectionId) || null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function pollInitStatus(session, connectionId, stderr) {
|
|
470
|
+
const startedAt = Date.now();
|
|
471
|
+
const cap = 30 * 60 * 1000; // 30 min
|
|
472
|
+
let lastStage = null;
|
|
473
|
+
const onSigint = () => {
|
|
474
|
+
stderr.write("\n[deepsql] Stopped polling (init still running server-side).\n");
|
|
475
|
+
process.exit(130);
|
|
476
|
+
};
|
|
477
|
+
process.once("SIGINT", onSigint);
|
|
478
|
+
try {
|
|
479
|
+
while (Date.now() - startedAt < cap) {
|
|
480
|
+
let status;
|
|
481
|
+
try {
|
|
482
|
+
status = await request(session.baseUrl, `/connections/${encodeURIComponent(connectionId)}/init-status`, {
|
|
483
|
+
token: session.token,
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err instanceof ApiError && err.status === 404) return; // older backend, no init-status endpoint
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
const stage = status?.stage || status?.status || "UNKNOWN";
|
|
490
|
+
if (stage !== lastStage) {
|
|
491
|
+
stderr.write(`[deepsql] init: ${stage}${status?.progressPercent != null ? ` (${status.progressPercent}%)` : ""}\n`);
|
|
492
|
+
lastStage = stage;
|
|
493
|
+
}
|
|
494
|
+
if (stage === "COMPLETED" || stage === "FAILED") {
|
|
495
|
+
if (stage === "FAILED") process.exitCode = 1;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
499
|
+
}
|
|
500
|
+
stderr.write(`[deepsql] init poll timed out after 30m. Use \`deepsql connections init ${connectionId} --wait\` to keep watching.\n`);
|
|
501
|
+
} finally {
|
|
502
|
+
process.removeListener("SIGINT", onSigint);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ─── interactive prompt for `add` ──────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
async function promptInteractive({ withCloud = false } = {}) {
|
|
509
|
+
const cfg = {};
|
|
510
|
+
cfg.connectionName = await ui.input({ message: "Connection name:", required: true });
|
|
511
|
+
cfg.dbType = await ui.select({
|
|
512
|
+
message: "Database type:",
|
|
513
|
+
choices: [
|
|
514
|
+
{ name: "PostgreSQL", value: "postgres" },
|
|
515
|
+
{ name: "MySQL", value: "mysql" },
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
cfg.host = await ui.input({ message: "Host:", required: true });
|
|
519
|
+
cfg.port = parseInt(
|
|
520
|
+
await ui.input({
|
|
521
|
+
message: "Port:",
|
|
522
|
+
default: cfg.dbType === "postgres" ? "5432" : "3306",
|
|
523
|
+
required: true,
|
|
524
|
+
}),
|
|
525
|
+
10,
|
|
526
|
+
);
|
|
527
|
+
cfg.database = await ui.input({ message: "Database name:", required: true });
|
|
528
|
+
cfg.username = await ui.input({ message: "Username:", required: true });
|
|
529
|
+
cfg.password = await promptPassword("Password: ");
|
|
530
|
+
|
|
531
|
+
const wantSsl = await ui.confirm({ message: "Configure SSL?", default: false });
|
|
532
|
+
if (wantSsl) {
|
|
533
|
+
cfg.sslMode = await ui.select({
|
|
534
|
+
message: "SSL mode:",
|
|
535
|
+
choices: [
|
|
536
|
+
{ name: "server-only (verify server)", value: "server-only" },
|
|
537
|
+
{ name: "server-client (mutual TLS)", value: "server-client" },
|
|
538
|
+
{ name: "none", value: "none" },
|
|
539
|
+
],
|
|
540
|
+
default: "server-only",
|
|
541
|
+
});
|
|
542
|
+
if (cfg.sslMode !== "none") {
|
|
543
|
+
cfg.sslCaCertificate = await ui.input({ message: "CA cert (paste PEM, $VAR, or @file:<path>):", required: false });
|
|
544
|
+
if (cfg.sslMode === "server-client") {
|
|
545
|
+
cfg.sslClientCertificate = await ui.input({ message: "Client cert (PEM/$VAR/@file:):" });
|
|
546
|
+
cfg.sslClientKey = await ui.input({ message: "Client key (PEM/$VAR/@file:):" });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const wantSsh = await ui.confirm({ message: "Configure SSH tunnel (bastion)?", default: false });
|
|
552
|
+
if (wantSsh) {
|
|
553
|
+
cfg.sshEnabled = true;
|
|
554
|
+
cfg.sshHost = await ui.input({ message: "SSH host:", required: true });
|
|
555
|
+
cfg.sshPort = parseInt(await ui.input({ message: "SSH port:", default: "22", required: true }), 10);
|
|
556
|
+
cfg.sshUsername = await ui.input({ message: "SSH username:", required: true });
|
|
557
|
+
cfg.sshAuthType = await ui.select({
|
|
558
|
+
message: "SSH auth:",
|
|
559
|
+
choices: [
|
|
560
|
+
{ name: "Private key", value: "PRIVATE_KEY" },
|
|
561
|
+
{ name: "Password", value: "PASSWORD" },
|
|
562
|
+
],
|
|
563
|
+
default: "PRIVATE_KEY",
|
|
564
|
+
});
|
|
565
|
+
if (cfg.sshAuthType === "PRIVATE_KEY") {
|
|
566
|
+
cfg.sshPrivateKey = await ui.input({ message: "Private key (PEM/$VAR/@file:):", required: true });
|
|
567
|
+
const passphrase = await promptPassword("Key passphrase (blank for none): ");
|
|
568
|
+
if (passphrase) cfg.sshPassphrase = passphrase;
|
|
569
|
+
} else {
|
|
570
|
+
cfg.sshPassword = await promptPassword("SSH password: ");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (withCloud) {
|
|
575
|
+
cfg.cloudProvider = await ui.select({
|
|
576
|
+
message: "Cloud provider:",
|
|
577
|
+
choices: [
|
|
578
|
+
{ name: "AWS", value: "aws" },
|
|
579
|
+
{ name: "Azure", value: "azure" },
|
|
580
|
+
{ name: "GCP", value: "gcp" },
|
|
581
|
+
{ name: "Self-hosted", value: "self-hosted" },
|
|
582
|
+
],
|
|
583
|
+
});
|
|
584
|
+
cfg.managedService = await ui.input({ message: "Managed service (rds, aurora, cloud-sql, …):", required: false });
|
|
585
|
+
cfg.instanceClass = await ui.input({ message: "Instance class (e.g. db.r6g.xlarge):", required: false });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return cfg;
|
|
39
589
|
}
|
|
40
590
|
|
|
41
591
|
module.exports = { run };
|
package/src/commands/digest.js
CHANGED
|
@@ -23,12 +23,10 @@ const DEFAULT_LIST_COUNT = 10;
|
|
|
23
23
|
const MAX_COUNT = 100;
|
|
24
24
|
|
|
25
25
|
async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
26
|
-
if (!opts.connection) {
|
|
27
|
-
throw new Error(
|
|
28
|
-
"--connection <name> is required. Digests are per-connection — pick one from `deepsql connections list`.",
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
26
|
const session = resolveSession(opts);
|
|
27
|
+
// Digests are per-connection. The resolver pulls from --connection, then
|
|
28
|
+
// DEEPSQL_CONNECTION, then the saved default; throws a friendly hint if
|
|
29
|
+
// none of those are set.
|
|
32
30
|
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
33
31
|
const sub = opts.positional[0];
|
|
34
32
|
|
package/src/commands/explain.js
CHANGED
|
@@ -7,7 +7,6 @@ const { resolveSession } = require("./_session");
|
|
|
7
7
|
const { resolveConnectionId } = require("./_connections");
|
|
8
8
|
|
|
9
9
|
async function run(opts, { stdout = process.stdout } = {}) {
|
|
10
|
-
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
11
10
|
const sql = readSqlInput(opts);
|
|
12
11
|
// EXPLAIN ANALYZE is mutating; require plain EXPLAIN.
|
|
13
12
|
const validation = validateReadOnlySql(sql, { allowExplain: false });
|
package/src/commands/query.js
CHANGED
|
@@ -7,7 +7,6 @@ const { resolveSession } = require("./_session");
|
|
|
7
7
|
const { resolveConnectionId } = require("./_connections");
|
|
8
8
|
|
|
9
9
|
async function run(opts, { stdout = process.stdout } = {}) {
|
|
10
|
-
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
11
10
|
const sql = readSqlInput(opts);
|
|
12
11
|
const validation = validateReadOnlySql(sql, { allowExplain: true });
|
|
13
12
|
if (!validation.ok) throw new Error(validation.reason);
|
|
@@ -12,7 +12,6 @@ const { resolveSession } = require("./_session");
|
|
|
12
12
|
const { resolveConnectionId } = require("./_connections");
|
|
13
13
|
|
|
14
14
|
async function run(opts, { stdout = process.stdout } = {}) {
|
|
15
|
-
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
16
15
|
|
|
17
16
|
const session = resolveSession(opts);
|
|
18
17
|
const connectionId = await resolveConnectionId(session, opts.connection);
|
package/src/commands/schema.js
CHANGED
|
@@ -5,7 +5,6 @@ const { resolveSession } = require("./_session");
|
|
|
5
5
|
const { resolveConnectionId } = require("./_connections");
|
|
6
6
|
|
|
7
7
|
async function run(opts, { stdout = process.stdout } = {}) {
|
|
8
|
-
if (!opts.connection) throw new Error("--connection <name> is required.");
|
|
9
8
|
const session = resolveSession(opts);
|
|
10
9
|
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
11
10
|
const sub = opts.positional[0] || "tables";
|
|
@@ -38,9 +38,8 @@ async function run(opts, io = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
const handler = SUBCOMMANDS[sub];
|
|
40
40
|
if (!handler) throw new Error(`Unknown slow-queries subcommand: ${sub}.`);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
41
|
+
// The resolver provides default-connection fallback; we only let `delete`
|
|
42
|
+
// run without one because it can also operate by --history-id.
|
|
44
43
|
return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
|
|
45
44
|
}
|
|
46
45
|
|