@deepsql/mcp 0.5.0 → 0.8.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/deepsql-phase1-lib.js +230 -36
- package/deepsql-phase1-server.js +1 -1
- package/package.json +5 -2
- package/src/api/client.js +1 -1
- package/src/cli.js +105 -9
- package/src/commands/_users.js +55 -0
- package/src/commands/access.js +225 -0
- package/src/commands/admin.test.js +135 -0
- package/src/commands/anti-patterns.js +77 -0
- package/src/commands/brain-context.js +68 -0
- package/src/commands/business-rules.js +59 -0
- package/src/commands/permissions.js +149 -0
- package/src/commands/relationships.js +56 -0
- package/src/commands/setup.js +220 -0
- package/src/commands/slow-queries.js +340 -0
- package/src/commands/users.js +276 -0
- package/src/ui/editor.js +61 -0
- package/src/ui/prompts.js +60 -0
- package/src/ui/sse.js +97 -0
- package/src/commands/ask.js +0 -44
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql slow-queries` — read slow-query analyses, trigger new ones, stream
|
|
5
|
+
* AI optimization steps live, and clean up history.
|
|
6
|
+
*
|
|
7
|
+
* deepsql slow-queries latest --connection <name> [--json]
|
|
8
|
+
* deepsql slow-queries history --connection <name> [N] [--json]
|
|
9
|
+
* deepsql slow-queries analyze --connection <name>
|
|
10
|
+
* [--time-range LAST_24_HOURS|LAST_HOUR] [--threshold-ms <ms>] [--limit <n>]
|
|
11
|
+
* deepsql slow-queries optimize --connection <name> --query-id <id>
|
|
12
|
+
* (streams SSE; use --query-text to skip history lookup)
|
|
13
|
+
* deepsql slow-queries delete --history-id <id> [--yes]
|
|
14
|
+
*
|
|
15
|
+
* The optimize subcommand follows the SSE protocol from
|
|
16
|
+
* /slow-queries/optimize/stream — `step` events go to stderr, the final
|
|
17
|
+
* `result` event prints to stdout. Honours SIGINT.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { ApiError, request } = require("../api/client");
|
|
21
|
+
const { streamSse } = require("../ui/sse");
|
|
22
|
+
const { resolveSession } = require("./_session");
|
|
23
|
+
const { resolveConnectionId } = require("./_connections");
|
|
24
|
+
const ui = require("../ui/prompts");
|
|
25
|
+
|
|
26
|
+
const SUBCOMMANDS = {
|
|
27
|
+
latest: cmdLatest,
|
|
28
|
+
history: cmdHistory,
|
|
29
|
+
analyze: cmdAnalyze,
|
|
30
|
+
optimize: cmdOptimize,
|
|
31
|
+
delete: cmdDelete,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async function run(opts, io = {}) {
|
|
35
|
+
const sub = opts.positional[0];
|
|
36
|
+
if (!sub) {
|
|
37
|
+
throw new Error("Usage: deepsql slow-queries <latest|history|analyze|optimize|delete> ...");
|
|
38
|
+
}
|
|
39
|
+
const handler = SUBCOMMANDS[sub];
|
|
40
|
+
if (!handler) throw new Error(`Unknown slow-queries subcommand: ${sub}.`);
|
|
41
|
+
if (!opts.connection && sub !== "delete") {
|
|
42
|
+
throw new Error("--connection <name> is required.");
|
|
43
|
+
}
|
|
44
|
+
return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wrap(handler) {
|
|
48
|
+
return async (opts, io) => {
|
|
49
|
+
try {
|
|
50
|
+
return await handler(opts, io);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
53
|
+
throw new Error("Access denied — slow-query operations require permissions on this connection.");
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── latest ────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function cmdLatest(opts, { stdout = process.stdout } = {}) {
|
|
63
|
+
const session = resolveSession(opts);
|
|
64
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
65
|
+
const result = await request(
|
|
66
|
+
session.baseUrl,
|
|
67
|
+
`/slow-queries/latest/${encodeURIComponent(connectionId)}`,
|
|
68
|
+
{ token: session.token },
|
|
69
|
+
);
|
|
70
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── history ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async function cmdHistory(opts, { stdout = process.stdout } = {}) {
|
|
76
|
+
const session = resolveSession(opts);
|
|
77
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
78
|
+
const summaries = await request(
|
|
79
|
+
session.baseUrl,
|
|
80
|
+
`/slow-queries/history/${encodeURIComponent(connectionId)}`,
|
|
81
|
+
{ token: session.token },
|
|
82
|
+
);
|
|
83
|
+
const items = Array.isArray(summaries) ? summaries : [];
|
|
84
|
+
const limitArg = parseCount(opts.positional[0]) || parseCount(opts.count);
|
|
85
|
+
const trimmed = limitArg ? items.slice(0, limitArg) : items;
|
|
86
|
+
|
|
87
|
+
if (opts.json) {
|
|
88
|
+
stdout.write(`${JSON.stringify(trimmed, null, 2)}\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (trimmed.length === 0) {
|
|
92
|
+
stdout.write("No history.\n");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
printHistory(stdout, trimmed);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── analyze ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function cmdAnalyze(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
101
|
+
const session = resolveSession(opts);
|
|
102
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
103
|
+
const timeRange = (opts.timeRange || "LAST_24_HOURS").toUpperCase();
|
|
104
|
+
const thresholdMs = parseNum(opts.thresholdMs) || 100;
|
|
105
|
+
const limit = parseNum(opts.limit) || 10;
|
|
106
|
+
|
|
107
|
+
stderr.write(`Analyzing slow queries on ${opts.connection} (range=${timeRange}, threshold=${thresholdMs}ms)…\n`);
|
|
108
|
+
const result = await request(session.baseUrl, "/slow-queries/analyze", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
token: session.token,
|
|
111
|
+
timeoutMs: 240000,
|
|
112
|
+
json: { connectionId, timeRange, thresholdMs, limit },
|
|
113
|
+
});
|
|
114
|
+
if (opts.json) {
|
|
115
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const queries = (result && (result.queries || result.slowQueries)) || [];
|
|
119
|
+
if (queries.length === 0) {
|
|
120
|
+
stdout.write("No slow queries detected in the selected window.\n");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
printQueries(stdout, queries);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── optimize (SSE) ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function cmdOptimize(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
129
|
+
const session = resolveSession(opts);
|
|
130
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
131
|
+
|
|
132
|
+
let queryText = opts.queryText;
|
|
133
|
+
let queryId = opts.queryId;
|
|
134
|
+
|
|
135
|
+
// If --query-id is given, look up the queryText from the latest analysis so
|
|
136
|
+
// the user doesn't have to paste raw SQL into the terminal.
|
|
137
|
+
if (!queryText) {
|
|
138
|
+
if (!queryId) throw new Error("Pass --query-id <id> or --query-text \"<sql>\".");
|
|
139
|
+
queryText = await lookupQueryText(session, connectionId, queryId);
|
|
140
|
+
if (!queryText) {
|
|
141
|
+
throw new Error(`Could not find query ${queryId} in the latest analysis. Pass --query-text "<sql>" instead.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const onSigint = () => {
|
|
147
|
+
stderr.write("\n[deepsql] cancelling optimization…\n");
|
|
148
|
+
controller.abort();
|
|
149
|
+
};
|
|
150
|
+
process.once("SIGINT", onSigint);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const stream = streamSse(session.baseUrl, "/slow-queries/optimize/stream", {
|
|
154
|
+
token: session.token,
|
|
155
|
+
query: { connectionId, queryText, queryId, sampleQuery: opts.sampleQuery },
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
for await (const message of stream) {
|
|
159
|
+
if (message.event === "step") {
|
|
160
|
+
const parsed = safeParse(message.data);
|
|
161
|
+
const status = parsed?.status || "step";
|
|
162
|
+
const text = parsed?.message || message.data;
|
|
163
|
+
stderr.write(`[${status}] ${text}\n`);
|
|
164
|
+
} else if (message.event === "result") {
|
|
165
|
+
if (opts.json) {
|
|
166
|
+
stdout.write(`${message.data}\n`);
|
|
167
|
+
} else {
|
|
168
|
+
const parsed = safeParse(message.data);
|
|
169
|
+
if (parsed && parsed.optimizedQuery) {
|
|
170
|
+
stdout.write(`\n--- Optimized query ---\n${parsed.optimizedQuery}\n`);
|
|
171
|
+
}
|
|
172
|
+
if (parsed && parsed.recommendations && parsed.recommendations.length) {
|
|
173
|
+
stdout.write("\n--- Recommendations ---\n");
|
|
174
|
+
for (const r of parsed.recommendations) {
|
|
175
|
+
stdout.write(`• ${r.title || r.summary || JSON.stringify(r)}\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!parsed || (!parsed.optimizedQuery && !(parsed.recommendations || []).length)) {
|
|
179
|
+
stdout.write(`${message.data}\n`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} else if (message.event === "error") {
|
|
183
|
+
const parsed = safeParse(message.data);
|
|
184
|
+
throw new Error(`Optimization failed: ${parsed?.message || message.data}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (controller.signal.aborted) {
|
|
189
|
+
process.exitCode = 130; // 128 + SIGINT
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
} finally {
|
|
194
|
+
process.removeListener("SIGINT", onSigint);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function lookupQueryText(session, connectionId, queryId) {
|
|
199
|
+
const latest = await request(
|
|
200
|
+
session.baseUrl,
|
|
201
|
+
`/slow-queries/latest/${encodeURIComponent(connectionId)}`,
|
|
202
|
+
{ token: session.token },
|
|
203
|
+
);
|
|
204
|
+
const data = latest?.analysisData || latest?.analysis || latest || {};
|
|
205
|
+
const queries = data.queries || data.slowQueries || [];
|
|
206
|
+
const hit = queries.find((q) => String(q.queryId || q.id) === String(queryId));
|
|
207
|
+
return hit ? (hit.queryText || hit.normalizedQuery || hit.query) : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── delete ────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async function cmdDelete(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
213
|
+
const session = resolveSession(opts);
|
|
214
|
+
|
|
215
|
+
if (opts.historyId) {
|
|
216
|
+
if (!opts.yes) {
|
|
217
|
+
const ok = await ui.confirm({
|
|
218
|
+
message: `Delete history entry ${opts.historyId}?`,
|
|
219
|
+
default: false,
|
|
220
|
+
});
|
|
221
|
+
if (!ok) {
|
|
222
|
+
stderr.write("Aborted.\n");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await request(session.baseUrl, `/slow-queries/history/${encodeURIComponent(opts.historyId)}`, {
|
|
227
|
+
method: "DELETE",
|
|
228
|
+
token: session.token,
|
|
229
|
+
});
|
|
230
|
+
stdout.write(`Deleted history entry ${opts.historyId}.\n`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (opts.connection) {
|
|
234
|
+
if (!opts.yes) {
|
|
235
|
+
const ok = await ui.confirm({
|
|
236
|
+
message: `Delete ALL slow-query history for ${opts.connection}? This is destructive.`,
|
|
237
|
+
default: false,
|
|
238
|
+
});
|
|
239
|
+
if (!ok) {
|
|
240
|
+
stderr.write("Aborted.\n");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
245
|
+
await request(
|
|
246
|
+
session.baseUrl,
|
|
247
|
+
`/slow-queries/history/connection/${encodeURIComponent(connectionId)}`,
|
|
248
|
+
{ method: "DELETE", token: session.token },
|
|
249
|
+
);
|
|
250
|
+
stdout.write(`Deleted all history for ${opts.connection}.\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
throw new Error("Pass --history-id <id> or --connection <name>.");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function parseCount(v) {
|
|
259
|
+
if (v == null) return null;
|
|
260
|
+
const n = Number.parseInt(v, 10);
|
|
261
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseNum(v) {
|
|
265
|
+
if (v == null) return null;
|
|
266
|
+
const n = Number(v);
|
|
267
|
+
return Number.isFinite(n) ? n : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function safeParse(text) {
|
|
271
|
+
if (!text) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(text);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function printHistory(stdout, items) {
|
|
280
|
+
const rows = items.map((h) => ({
|
|
281
|
+
id: String(h.id ?? h.historyId ?? ""),
|
|
282
|
+
createdAt: formatTime(h.createdAt || h.timestamp || h.analysisTime),
|
|
283
|
+
queries: String(h.queryCount ?? h.totalQueries ?? "?"),
|
|
284
|
+
summary: trim(h.summary || h.headline || "", 70),
|
|
285
|
+
}));
|
|
286
|
+
const cols = [
|
|
287
|
+
{ key: "id", label: "ID" },
|
|
288
|
+
{ key: "createdAt", label: "WHEN" },
|
|
289
|
+
{ key: "queries", label: "#" },
|
|
290
|
+
{ key: "summary", label: "SUMMARY" },
|
|
291
|
+
];
|
|
292
|
+
printTable(stdout, cols, rows);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function printQueries(stdout, queries) {
|
|
296
|
+
const rows = queries.map((q) => ({
|
|
297
|
+
id: String(q.queryId || q.id || ""),
|
|
298
|
+
severity: q.severity || "?",
|
|
299
|
+
avgMs: q.avgExecutionTimeMs != null ? Math.round(q.avgExecutionTimeMs).toString() : "?",
|
|
300
|
+
calls: String(q.callCount ?? q.executionCount ?? "?"),
|
|
301
|
+
sample: trim(q.queryText || q.normalizedQuery || q.query || "", 60),
|
|
302
|
+
}));
|
|
303
|
+
const cols = [
|
|
304
|
+
{ key: "id", label: "QUERY ID" },
|
|
305
|
+
{ key: "severity", label: "SEVERITY" },
|
|
306
|
+
{ key: "avgMs", label: "AVG MS" },
|
|
307
|
+
{ key: "calls", label: "CALLS" },
|
|
308
|
+
{ key: "sample", label: "QUERY" },
|
|
309
|
+
];
|
|
310
|
+
printTable(stdout, cols, rows);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function printTable(stdout, cols, rows) {
|
|
314
|
+
const widths = cols.map((c) => Math.max(c.label.length, ...rows.map((r) => r[c.key].length)));
|
|
315
|
+
const header = cols.map((c, i) => c.label.padEnd(widths[i])).join(" ");
|
|
316
|
+
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
317
|
+
stdout.write(`${header}\n${sep}\n`);
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
stdout.write(`${cols.map((c, i) => row[c.key].padEnd(widths[i])).join(" ")}\n`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatTime(value) {
|
|
324
|
+
if (!value) return "—";
|
|
325
|
+
try {
|
|
326
|
+
const d = new Date(value);
|
|
327
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
328
|
+
return d.toISOString().replace("T", " ").slice(0, 16) + "Z";
|
|
329
|
+
} catch {
|
|
330
|
+
return String(value);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function trim(text, max) {
|
|
335
|
+
if (!text) return "";
|
|
336
|
+
const s = String(text).replace(/\s+/g, " ").trim();
|
|
337
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = { run };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql users` — manage workspace users (admin-only).
|
|
5
|
+
*
|
|
6
|
+
* deepsql users list [--json]
|
|
7
|
+
* deepsql users get <email-or-id>
|
|
8
|
+
* deepsql users add [<email>] [--role <r>] [--name <n>] [--password <p>] [--password-stdin]
|
|
9
|
+
* deepsql users set-role <email> <role>
|
|
10
|
+
* deepsql users lock <email>
|
|
11
|
+
* deepsql users unlock <email>
|
|
12
|
+
* deepsql users disable <email>
|
|
13
|
+
* deepsql users resend-invite <email>
|
|
14
|
+
* deepsql users reset-password <email> [--password-stdin]
|
|
15
|
+
* deepsql users delete <email> [--yes]
|
|
16
|
+
*
|
|
17
|
+
* All endpoints under /admin/users/** require ROLE_ADMIN on the calling token.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { ApiError, request } = require("../api/client");
|
|
21
|
+
const { resolveSession } = require("./_session");
|
|
22
|
+
const { resolveUser, listUsers, clearUserCache } = require("./_users");
|
|
23
|
+
const { promptPassword, readSingleLineFromStdin } = require("../auth/prompt");
|
|
24
|
+
const ui = require("../ui/prompts");
|
|
25
|
+
|
|
26
|
+
async function run(opts, io = {}) {
|
|
27
|
+
const sub = opts.positional[0];
|
|
28
|
+
if (!sub) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Usage: deepsql users <list|get|add|set-role|lock|unlock|disable|resend-invite|reset-password|delete> ...",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const handler = SUBCOMMANDS[sub];
|
|
34
|
+
if (!handler) {
|
|
35
|
+
throw new Error(`Unknown users subcommand: ${sub}.`);
|
|
36
|
+
}
|
|
37
|
+
return wrapAdminErrors(handler)(
|
|
38
|
+
{
|
|
39
|
+
...opts,
|
|
40
|
+
// Drop the subcommand from positional so handlers see only their args.
|
|
41
|
+
positional: opts.positional.slice(1),
|
|
42
|
+
},
|
|
43
|
+
io,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wrapAdminErrors(handler) {
|
|
48
|
+
return async (opts, io) => {
|
|
49
|
+
try {
|
|
50
|
+
return await handler(opts, io);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"Access denied — managing users requires ADMIN role on the calling token.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async function cmdList(opts, { stdout = process.stdout } = {}) {
|
|
65
|
+
const session = resolveSession(opts);
|
|
66
|
+
const users = await listUsers(session);
|
|
67
|
+
if (opts.json) {
|
|
68
|
+
stdout.write(`${JSON.stringify(users, null, 2)}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (users.length === 0) {
|
|
72
|
+
stdout.write("No users.\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
printUsers(stdout, users);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── get ───────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async function cmdGet(opts, { stdout = process.stdout } = {}) {
|
|
81
|
+
const ref = opts.positional[0];
|
|
82
|
+
if (!ref) throw new Error("Pass an email, username, or id: `deepsql users get <ref>`.");
|
|
83
|
+
const session = resolveSession(opts);
|
|
84
|
+
const user = await resolveUser(session, ref);
|
|
85
|
+
// Hit the by-id endpoint to get the full record (list returns a summary).
|
|
86
|
+
const full = await request(session.baseUrl, `/admin/users/${user.id}`, { token: session.token });
|
|
87
|
+
stdout.write(`${JSON.stringify(full, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── add ───────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
async function cmdAdd(opts, { stdout = process.stdout } = {}) {
|
|
93
|
+
const session = resolveSession(opts);
|
|
94
|
+
|
|
95
|
+
const email = opts.positional[0] || opts.email || (await ui.input({
|
|
96
|
+
message: "Email:",
|
|
97
|
+
validate: (v) => /.+@.+\..+/.test(v) ? true : "Looks invalid",
|
|
98
|
+
}));
|
|
99
|
+
const username = opts.name || opts.username || (await ui.input({
|
|
100
|
+
message: "Display name:",
|
|
101
|
+
default: email.split("@")[0],
|
|
102
|
+
}));
|
|
103
|
+
const role = (opts.role || (await ui.select({
|
|
104
|
+
message: "Role:",
|
|
105
|
+
choices: [
|
|
106
|
+
{ name: "ADMIN", value: "ADMIN" },
|
|
107
|
+
{ name: "DEVELOPER", value: "DEVELOPER" },
|
|
108
|
+
{ name: "VIEWER", value: "VIEWER" },
|
|
109
|
+
],
|
|
110
|
+
default: "DEVELOPER",
|
|
111
|
+
}))).toUpperCase();
|
|
112
|
+
|
|
113
|
+
// `opts.password` is the login-flow toggle (boolean) or unset; it's never a
|
|
114
|
+
// password value here on purpose — argv would expose it via `ps`. Always
|
|
115
|
+
// prompt, or read from stdin with --password-stdin for CI.
|
|
116
|
+
let password = null;
|
|
117
|
+
if (opts.passwordStdin) {
|
|
118
|
+
password = await readSingleLineFromStdin();
|
|
119
|
+
} else if (process.stdin.isTTY) {
|
|
120
|
+
password = await ui.password({
|
|
121
|
+
message: "Initial password (leave blank to send invite by email):",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const body = { email, username, role };
|
|
126
|
+
if (password) body.password = password;
|
|
127
|
+
|
|
128
|
+
const created = await request(session.baseUrl, "/admin/users", {
|
|
129
|
+
method: "POST",
|
|
130
|
+
token: session.token,
|
|
131
|
+
json: body,
|
|
132
|
+
});
|
|
133
|
+
clearUserCache();
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
stdout.write(`${JSON.stringify(created, null, 2)}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
stdout.write(`Created user: ${created.email || email} (id ${created.id ?? "?"}, role ${created.role || role})\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── set-role ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function cmdSetRole(opts, { stdout = process.stdout } = {}) {
|
|
144
|
+
const ref = opts.positional[0];
|
|
145
|
+
const role = (opts.positional[1] || opts.role || "").toUpperCase();
|
|
146
|
+
if (!ref || !role) {
|
|
147
|
+
throw new Error("Usage: deepsql users set-role <email|id> <role>");
|
|
148
|
+
}
|
|
149
|
+
const session = resolveSession(opts);
|
|
150
|
+
const user = await resolveUser(session, ref);
|
|
151
|
+
const updated = await request(session.baseUrl, `/admin/users/${user.id}/role`, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
token: session.token,
|
|
154
|
+
json: { role },
|
|
155
|
+
});
|
|
156
|
+
stdout.write(`Updated ${user.email || user.username}: role=${updated.role || role}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── state toggles ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const cmdLock = makeStateToggle("lock");
|
|
162
|
+
const cmdUnlock = makeStateToggle("unlock");
|
|
163
|
+
const cmdDisable = makeStateToggle("disable");
|
|
164
|
+
const cmdResendInvite = makeStateToggle("resend-invite", "Sent invite email to");
|
|
165
|
+
|
|
166
|
+
function makeStateToggle(action, verbPrefix) {
|
|
167
|
+
return async function (opts, { stdout = process.stdout } = {}) {
|
|
168
|
+
const ref = opts.positional[0];
|
|
169
|
+
if (!ref) throw new Error(`Usage: deepsql users ${action} <email|id>`);
|
|
170
|
+
const session = resolveSession(opts);
|
|
171
|
+
const user = await resolveUser(session, ref);
|
|
172
|
+
await request(session.baseUrl, `/admin/users/${user.id}/${action}`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
token: session.token,
|
|
175
|
+
json: {},
|
|
176
|
+
});
|
|
177
|
+
const verb = verbPrefix || `${action[0].toUpperCase()}${action.slice(1)}ed`;
|
|
178
|
+
stdout.write(`${verb} ${user.email || user.username}.\n`);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── reset-password ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function cmdResetPassword(opts, { stdout = process.stdout } = {}) {
|
|
185
|
+
const ref = opts.positional[0];
|
|
186
|
+
if (!ref) throw new Error("Usage: deepsql users reset-password <email|id> [--password-stdin]");
|
|
187
|
+
const session = resolveSession(opts);
|
|
188
|
+
const user = await resolveUser(session, ref);
|
|
189
|
+
|
|
190
|
+
let password = null;
|
|
191
|
+
if (opts.passwordStdin) {
|
|
192
|
+
password = await readSingleLineFromStdin();
|
|
193
|
+
} else {
|
|
194
|
+
password = await promptPassword(`New password for ${user.email || user.username}: `);
|
|
195
|
+
const confirmPw = await promptPassword("Confirm: ");
|
|
196
|
+
if (password !== confirmPw) throw new Error("Passwords do not match.");
|
|
197
|
+
}
|
|
198
|
+
if (!password) throw new Error("Password is required.");
|
|
199
|
+
|
|
200
|
+
await request(session.baseUrl, `/admin/users/${user.id}/password`, {
|
|
201
|
+
method: "PUT",
|
|
202
|
+
token: session.token,
|
|
203
|
+
json: { password },
|
|
204
|
+
});
|
|
205
|
+
stdout.write(`Password reset for ${user.email || user.username}.\n`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── delete ────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async function cmdDelete(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
211
|
+
const ref = opts.positional[0];
|
|
212
|
+
if (!ref) throw new Error("Usage: deepsql users delete <email|id> [--yes]");
|
|
213
|
+
const session = resolveSession(opts);
|
|
214
|
+
const user = await resolveUser(session, ref);
|
|
215
|
+
|
|
216
|
+
if (!opts.yes) {
|
|
217
|
+
const ok = await ui.confirm({
|
|
218
|
+
message: `Delete ${user.email || user.username} (id ${user.id})? This cannot be undone.`,
|
|
219
|
+
default: false,
|
|
220
|
+
});
|
|
221
|
+
if (!ok) {
|
|
222
|
+
stderr.write("Aborted.\n");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await request(session.baseUrl, `/admin/users/${user.id}`, {
|
|
227
|
+
method: "DELETE",
|
|
228
|
+
token: session.token,
|
|
229
|
+
});
|
|
230
|
+
clearUserCache();
|
|
231
|
+
stdout.write(`Deleted ${user.email || user.username}.\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
// Defined here, after all cmd* definitions, so the const-bound state-toggle
|
|
237
|
+
// functions are initialized before this object captures them.
|
|
238
|
+
const SUBCOMMANDS = {
|
|
239
|
+
list: cmdList,
|
|
240
|
+
get: cmdGet,
|
|
241
|
+
add: cmdAdd,
|
|
242
|
+
invite: cmdAdd, // alias — POST /admin/users/invite is identical to /admin/users
|
|
243
|
+
"set-role": cmdSetRole,
|
|
244
|
+
lock: cmdLock,
|
|
245
|
+
unlock: cmdUnlock,
|
|
246
|
+
disable: cmdDisable,
|
|
247
|
+
"resend-invite": cmdResendInvite,
|
|
248
|
+
"reset-password": cmdResetPassword,
|
|
249
|
+
delete: cmdDelete,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function printUsers(stdout, users) {
|
|
253
|
+
const rows = users.map((u) => ({
|
|
254
|
+
id: String(u.id ?? ""),
|
|
255
|
+
email: u.email || "",
|
|
256
|
+
username: u.username || "",
|
|
257
|
+
role: u.role || "",
|
|
258
|
+
status: u.accountStatus || u.status || "",
|
|
259
|
+
}));
|
|
260
|
+
const cols = [
|
|
261
|
+
{ key: "id", label: "ID" },
|
|
262
|
+
{ key: "email", label: "EMAIL" },
|
|
263
|
+
{ key: "username", label: "NAME" },
|
|
264
|
+
{ key: "role", label: "ROLE" },
|
|
265
|
+
{ key: "status", label: "STATUS" },
|
|
266
|
+
];
|
|
267
|
+
const widths = cols.map((c) => Math.max(c.label.length, ...rows.map((r) => r[c.key].length)));
|
|
268
|
+
const header = cols.map((c, i) => c.label.padEnd(widths[i])).join(" ");
|
|
269
|
+
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
270
|
+
stdout.write(`${header}\n${sep}\n`);
|
|
271
|
+
for (const row of rows) {
|
|
272
|
+
stdout.write(`${cols.map((c, i) => row[c.key].padEnd(widths[i])).join(" ")}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { run };
|
package/src/ui/editor.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spawn the user's $EDITOR on a tempfile and return the edited contents.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order: VISUAL → EDITOR → vi (POSIX) / notepad (Windows).
|
|
7
|
+
* Tempfile is created mode 0600 in os.tmpdir(), removed on exit (success or
|
|
8
|
+
* cancel), and uses a randomised suffix so concurrent edits don't clash.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
const crypto = require("node:crypto");
|
|
15
|
+
const { spawn } = require("node:child_process");
|
|
16
|
+
|
|
17
|
+
function pickEditor() {
|
|
18
|
+
if (process.env.VISUAL) return process.env.VISUAL;
|
|
19
|
+
if (process.env.EDITOR) return process.env.EDITOR;
|
|
20
|
+
return process.platform === "win32" ? "notepad" : "vi";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function editText(initial, { suffix = ".txt", header = null } = {}) {
|
|
24
|
+
const random = crypto.randomBytes(6).toString("hex");
|
|
25
|
+
const tmp = path.join(os.tmpdir(), `deepsql-${random}${suffix}`);
|
|
26
|
+
let body = initial == null ? "" : String(initial);
|
|
27
|
+
if (header) body = `${header}\n\n${body}`;
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
30
|
+
const before = fs.readFileSync(tmp, "utf8");
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const editor = pickEditor();
|
|
35
|
+
const child = spawn(editor, [tmp], { stdio: "inherit" });
|
|
36
|
+
child.on("exit", (code) => {
|
|
37
|
+
if (code === 0) resolve();
|
|
38
|
+
else reject(new Error(`Editor exited with code ${code}`));
|
|
39
|
+
});
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const after = fs.readFileSync(tmp, "utf8");
|
|
44
|
+
return {
|
|
45
|
+
content: stripHeader(after, header),
|
|
46
|
+
changed: stripHeader(after, header) !== stripHeader(before, header),
|
|
47
|
+
};
|
|
48
|
+
} finally {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(tmp);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripHeader(text, header) {
|
|
56
|
+
if (!header) return text;
|
|
57
|
+
const headerBlock = `${header}\n\n`;
|
|
58
|
+
return text.startsWith(headerBlock) ? text.slice(headerBlock.length) : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { editText, pickEditor };
|