@deepsql/mcp 0.2.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "./bin/deepsql.js",
package/src/api/client.js CHANGED
@@ -32,7 +32,7 @@ function resolveUrl(baseUrl, path) {
32
32
  return new URL(withApi, normalizeBaseUrl(baseUrl)).toString();
33
33
  }
34
34
 
35
- async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, token, timeoutMs = 120000, query } = {}) {
35
+ async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, token, timeoutMs = 120000, query, returnHeaders = false } = {}) {
36
36
  let url;
37
37
  if (typeof pathOrUrl === "string" && /^https?:\/\//i.test(pathOrUrl)) {
38
38
  url = pathOrUrl;
@@ -87,7 +87,31 @@ async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, toke
87
87
  const message = (body && typeof body === "object" && (body.message || body.error)) || `HTTP ${response.status}`;
88
88
  throw new ApiError(message, { status: response.status, body });
89
89
  }
90
+ if (returnHeaders) {
91
+ // Node's fetch exposes Set-Cookie via getSetCookie() (Node 20+); the
92
+ // password-login flow needs the auth_token cookie value to mint a long-
93
+ // lived MCP token afterwards.
94
+ const setCookies = typeof response.headers.getSetCookie === "function"
95
+ ? response.headers.getSetCookie()
96
+ : [];
97
+ return { body, status: response.status, setCookies };
98
+ }
90
99
  return body;
91
100
  }
92
101
 
93
- module.exports = { ApiError, request, resolveUrl, normalizeBaseUrl };
102
+ /**
103
+ * Extract a single cookie value from an array of Set-Cookie header values.
104
+ *
105
+ * parseCookieValue(["auth_token=abc.def; Path=/; HttpOnly", "refresh_token=…"], "auth_token")
106
+ * => "abc.def"
107
+ */
108
+ function parseCookieValue(setCookies, name) {
109
+ if (!Array.isArray(setCookies)) return null;
110
+ for (const raw of setCookies) {
111
+ const match = raw && raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
112
+ if (match) return decodeURIComponent(match[1]);
113
+ }
114
+ return null;
115
+ }
116
+
117
+ module.exports = { ApiError, request, resolveUrl, normalizeBaseUrl, parseCookieValue };
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Direct username/password login — for headless boxes where neither a browser
5
+ * nor copy-paste of a device code is convenient (e.g. provisioning a fresh
6
+ * self-host VM via Ansible/cloud-init, or a CI runner that needs to mint a
7
+ * service-account token from a stored secret).
8
+ *
9
+ * Flow:
10
+ * 1. Prompt for email + password (or accept --email and --password-stdin).
11
+ * 2. POST /auth/login. The backend either:
12
+ * a) succeeds and sets the auth_token cookie containing a session JWT, or
13
+ * b) returns { challengeId, message } indicating an email-OTP step.
14
+ * 3. If a challenge is required, kick off /auth/email/start, prompt for the
15
+ * OTP, then POST /auth/email/verify. Same auth_token cookie is set on
16
+ * success.
17
+ * 4. With the JWT in hand, POST /auth/mcp-tokens to mint a long-lived MCP
18
+ * token. The session cookie is short-lived (minutes); the MCP token lives
19
+ * until revoked, which is what we want to persist to ~/.config/deepsql.
20
+ *
21
+ * Security notes:
22
+ * - We never persist the raw password. It's read from a TTY (echo off) or
23
+ * from stdin and then dropped after the login POST.
24
+ * - The session JWT is held only in memory between login and the
25
+ * mcp-tokens POST.
26
+ * - For CI-style use, callers should pipe the password via stdin
27
+ * (`--password-stdin`) rather than passing it as an argv flag, since argv
28
+ * shows up in `ps`.
29
+ */
30
+
31
+ const os = require("node:os");
32
+
33
+ const { ApiError, parseCookieValue, request } = require("../api/client");
34
+ const { prompt, promptPassword, readSingleLineFromStdin } = require("./prompt");
35
+
36
+ async function runPasswordFlow({ baseUrl, email, passwordStdin, hostname, clientLabel, log = () => {} }) {
37
+ const resolvedEmail = email && email.trim() ? email.trim() : await prompt("Email: ");
38
+ if (!resolvedEmail) throw new Error("Email is required.");
39
+
40
+ const password = passwordStdin
41
+ ? await readSingleLineFromStdin()
42
+ : await promptPassword("Password: ");
43
+ if (!password) throw new Error("Password is required.");
44
+
45
+ log(`Authenticating ${resolvedEmail} against ${baseUrl}…`);
46
+ let jwt = await postLoginAndExtractJwt(baseUrl, resolvedEmail, password);
47
+
48
+ if (!jwt) {
49
+ // Server returned a challenge — currently the only kind we handle is
50
+ // email OTP. Other challenges (e.g. authenticator-app MFA) should fall
51
+ // back to the browser flow with a clear error.
52
+ log("Server requires email verification. Sending OTP…");
53
+ jwt = await runEmailOtpChallenge(baseUrl, resolvedEmail);
54
+ }
55
+
56
+ log("Minting long-lived CLI token…");
57
+ const tokenName = buildTokenName(clientLabel, hostname);
58
+ const created = await request(baseUrl, "/auth/mcp-tokens", {
59
+ method: "POST",
60
+ token: jwt,
61
+ json: { name: tokenName },
62
+ });
63
+
64
+ return {
65
+ token: created.token,
66
+ token_id: created.id,
67
+ username: extractUsername(created, resolvedEmail),
68
+ expires_at: created.expiresAt || null,
69
+ };
70
+ }
71
+
72
+ async function postLoginAndExtractJwt(baseUrl, email, password) {
73
+ let result;
74
+ try {
75
+ result = await request(baseUrl, "/auth/login", {
76
+ method: "POST",
77
+ json: { email, password },
78
+ returnHeaders: true,
79
+ });
80
+ } catch (err) {
81
+ if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
82
+ const detail = err.body && typeof err.body === "object" ? err.body.message : null;
83
+ throw new Error(detail || "Invalid email or password.");
84
+ }
85
+ throw err;
86
+ }
87
+
88
+ const jwt = parseCookieValue(result.setCookies, "auth_token");
89
+ if (jwt) return jwt;
90
+
91
+ // No cookie on the response — must be a challenge handoff. Body shape is
92
+ // { challengeId, message }.
93
+ if (result.body && result.body.challengeId) return null;
94
+
95
+ throw new Error(
96
+ "Login succeeded but no session token was issued — your DeepSQL instance may use SSO that the CLI can't drive. Use `deepsql login` (browser flow) instead.",
97
+ );
98
+ }
99
+
100
+ async function runEmailOtpChallenge(baseUrl, email) {
101
+ // Fresh challenge so /email/start has something to act on. The login call
102
+ // above already created a challenge but didn't expose its id; calling
103
+ // /email/start without a prior challenge id is supported and starts a new
104
+ // one for the same user.
105
+ let started;
106
+ try {
107
+ started = await request(baseUrl, "/auth/email/start", {
108
+ method: "POST",
109
+ json: { email },
110
+ });
111
+ } catch (err) {
112
+ throw new Error(
113
+ `Could not start email verification: ${err.message}. Use \`deepsql login\` (browser flow) if your account requires SSO or authenticator MFA.`,
114
+ );
115
+ }
116
+
117
+ const otp = await prompt(`Email OTP (sent to ${email}): `);
118
+ if (!otp) throw new Error("OTP is required.");
119
+
120
+ const result = await request(baseUrl, "/auth/email/verify", {
121
+ method: "POST",
122
+ json: { challengeId: started.challengeId, otp },
123
+ returnHeaders: true,
124
+ });
125
+ const jwt = parseCookieValue(result.setCookies, "auth_token");
126
+ if (!jwt) {
127
+ throw new Error("Email verification did not return a session token. Aborting.");
128
+ }
129
+ return jwt;
130
+ }
131
+
132
+ function extractUsername(createdTokenResponse, fallbackEmail) {
133
+ if (createdTokenResponse && typeof createdTokenResponse === "object") {
134
+ if (createdTokenResponse.username) return createdTokenResponse.username;
135
+ if (createdTokenResponse.userId) return String(createdTokenResponse.userId);
136
+ }
137
+ return fallbackEmail;
138
+ }
139
+
140
+ function buildTokenName(clientLabel, hostname) {
141
+ const parts = ["CLI"];
142
+ parts.push(clientLabel && clientLabel.trim() ? clientLabel.trim() : "deepsql");
143
+ if (hostname && hostname.trim()) parts.push(`@ ${hostname.trim()}`);
144
+ parts.push("(password)");
145
+ const name = parts.join(" ");
146
+ return name.length > 120 ? name.slice(0, 120) : name;
147
+ }
148
+
149
+ module.exports = { runPasswordFlow };
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Tiny interactive prompt helpers — no dependency on a TTY library.
5
+ *
6
+ * For passwords we mute the terminal echo so the value does not show on screen
7
+ * or get captured by ttyrec / screen-recording. If stdin is not a TTY (piped
8
+ * input), we read a single line as-is.
9
+ */
10
+
11
+ const readline = require("node:readline");
12
+
13
+ function prompt(question) {
14
+ return new Promise((resolve) => {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ }
22
+
23
+ function promptPassword(question) {
24
+ if (!process.stdin.isTTY) {
25
+ return readSingleLineFromStdin();
26
+ }
27
+ return new Promise((resolve, reject) => {
28
+ process.stderr.write(question);
29
+ const stdin = process.stdin;
30
+ const previousRaw = stdin.isRaw;
31
+ stdin.setRawMode(true);
32
+ stdin.resume();
33
+ stdin.setEncoding("utf8");
34
+
35
+ const ETX = "\u0003"; // Ctrl-C
36
+ const EOT = "\u0004"; // Ctrl-D
37
+ const BS = "\u0008"; // ASCII backspace
38
+ const DEL = "\u007f"; // most modern terminals send this on backspace
39
+
40
+ let buffer = "";
41
+ const onData = (char) => {
42
+ switch (char) {
43
+ case "\r":
44
+ case "\n":
45
+ case EOT:
46
+ finish();
47
+ return;
48
+ case ETX:
49
+ cleanup();
50
+ process.stderr.write("\n");
51
+ reject(new Error("Cancelled"));
52
+ return;
53
+ case BS:
54
+ case DEL:
55
+ buffer = buffer.slice(0, -1);
56
+ return;
57
+ default:
58
+ // Accept printable chars only; ignore other ANSI control sequences.
59
+ if (char.charCodeAt(0) >= 32) buffer += char;
60
+ }
61
+ };
62
+
63
+ const cleanup = () => {
64
+ stdin.removeListener("data", onData);
65
+ try {
66
+ stdin.setRawMode(previousRaw);
67
+ } catch {}
68
+ stdin.pause();
69
+ };
70
+
71
+ const finish = () => {
72
+ cleanup();
73
+ process.stderr.write("\n");
74
+ resolve(buffer);
75
+ };
76
+
77
+ stdin.on("data", onData);
78
+ });
79
+ }
80
+
81
+ function readSingleLineFromStdin() {
82
+ return new Promise((resolve, reject) => {
83
+ let buffer = "";
84
+ process.stdin.setEncoding("utf8");
85
+ process.stdin.on("data", (chunk) => {
86
+ buffer += chunk;
87
+ });
88
+ process.stdin.on("end", () => {
89
+ // Strip a single trailing newline if the caller did echo \$PWD | …
90
+ resolve(buffer.replace(/\r?\n$/, ""));
91
+ });
92
+ process.stdin.on("error", reject);
93
+ });
94
+ }
95
+
96
+ module.exports = { prompt, promptPassword, readSingleLineFromStdin };
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * small for the self-hosted distribution and makes it trivial to embed in
8
8
  * scripts. Supports:
9
9
  * - boolean flags: --json, --device, --browser, --no-browser
10
- * - value flags: --url <url>, --token <t>, --connection <id>, --limit 50
10
+ * - value flags: --url <url>, --token <t>, --connection <name>, --limit 50
11
11
  * - subcommands: deepsql connections list, deepsql config show
12
12
  * - positional: deepsql ask "what tables exist?"
13
13
  */
@@ -23,6 +23,7 @@ const COMMANDS = {
23
23
  query: () => require("./commands/query"),
24
24
  explain: () => require("./commands/explain"),
25
25
  schema: () => require("./commands/schema"),
26
+ digest: () => require("./commands/digest"),
26
27
  };
27
28
 
28
29
  const HELP = `deepsql — DeepSQL CLI
@@ -31,8 +32,14 @@ Usage:
31
32
  deepsql <command> [options]
32
33
 
33
34
  Commands:
34
- login [--url <url>] [--device|--browser] [--no-browser] [--label <name>]
35
+ login [--url <url>] [--device|--browser|--password] [--no-browser] [--label <name>]
36
+ [--email <email>] [--password-stdin]
35
37
  Authorize this CLI with a DeepSQL instance.
38
+ Default: browser callback (PKCE), or device-code
39
+ on headless boxes. Use --password for direct
40
+ email+password login on a fresh self-host VM
41
+ with no browser; pipe the password via stdin
42
+ with --password-stdin for non-interactive use.
36
43
  logout [--url <url>] Revoke and forget the saved token.
37
44
  whoami Show the user behind the saved token.
38
45
  config show List saved profiles.
@@ -40,14 +47,21 @@ Commands:
40
47
  config path Print the auth file path.
41
48
  mcp Run the stdio MCP server using the saved token.
42
49
  connections list [--json] List database connections.
43
- ask "<question>" --connection <id> [--chat <id>] [--json]
50
+ ask "<question>" --connection <name> [--chat <id>] [--json]
44
51
  Ask DeepSQL a question.
45
- query "<sql>" --connection <id> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
52
+ query "<sql>" --connection <name> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
46
53
  Run a read-only SQL query.
47
- explain "<sql>" --connection <id> [--file <path>] [--json]
54
+ explain "<sql>" --connection <name> [--file <path>] [--json]
48
55
  Get an EXPLAIN plan.
49
- schema [tables|objects] --connection <id>
56
+ schema [tables|objects] --connection <name>
50
57
  Dump schema or database objects as JSON.
58
+ digest [N] [--connection <name>] [--json]
59
+ Show the latest DeepSQL digest, or pass a
60
+ number to list the last N (e.g. digest 5).
61
+ digest list [--count N] [--connection <name>] [--json]
62
+ Explicit list form (same as digest <N>).
63
+ digest show <id> [--connection <name>] [--json]
64
+ Show one digest by id.
51
65
 
52
66
  Global options:
53
67
  --url <url> Override the DeepSQL base URL.
@@ -107,12 +121,16 @@ function buildOpts(parsed) {
107
121
  device: !!f.device,
108
122
  browser: !!f.browser,
109
123
  noBrowser: !!f.noBrowser,
124
+ password: !!f.password,
125
+ passwordStdin: !!f.passwordStdin,
126
+ email: f.email || null,
110
127
  label: f.label || null,
111
128
  connection: f.connection || f.c || null,
112
129
  chat: f.chat || null,
113
130
  user: f.user || null,
114
131
  project: f.project || null,
115
132
  limit: f.limit,
133
+ count: f.count || f.n || null,
116
134
  timeoutSeconds: f.timeoutSeconds,
117
135
  file: f.file || null,
118
136
  };
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Resolve a user-supplied connection identifier (name or UUID) to the
5
+ * canonical connection ID the backend expects.
6
+ *
7
+ * Backend `/connections` returns rows shaped roughly like:
8
+ * { id: "<uuid>", connectionName: "mylocalpg", databaseType: "postgresql", ... }
9
+ *
10
+ * Resolution rules:
11
+ * - If input matches a UUID pattern, treat it as an ID (one fetch saved).
12
+ * We still verify it exists so we can fail fast with a useful message,
13
+ * but only if a list fetch is cheap — for now, trust UUIDs.
14
+ * - Otherwise, fetch the list and match `connectionName` case-insensitively.
15
+ * - On ambiguous matches (rare — names are not unique by schema constraint),
16
+ * prefer an exact-case match; otherwise raise.
17
+ */
18
+
19
+ const { request } = require("../api/client");
20
+
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ let cachedList = null;
24
+
25
+ async function listConnections(session) {
26
+ if (cachedList) return cachedList;
27
+ cachedList = await request(session.baseUrl, "/connections", { token: session.token });
28
+ if (!Array.isArray(cachedList)) cachedList = [];
29
+ return cachedList;
30
+ }
31
+
32
+ async function resolveConnectionId(session, input) {
33
+ if (!input || typeof input !== "string") {
34
+ throw new Error("Pass a connection name or id with --connection.");
35
+ }
36
+ const trimmed = input.trim();
37
+ if (UUID_RE.test(trimmed)) return trimmed;
38
+
39
+ const connections = await listConnections(session);
40
+ const exact = connections.filter((c) => (c.connectionName || c.name) === trimmed);
41
+ if (exact.length === 1) return exact[0].id || exact[0].connectionId;
42
+
43
+ const ciMatches = connections.filter(
44
+ (c) => String(c.connectionName || c.name || "").toLowerCase() === trimmed.toLowerCase(),
45
+ );
46
+ if (ciMatches.length === 1) return ciMatches[0].id || ciMatches[0].connectionId;
47
+
48
+ if (ciMatches.length > 1) {
49
+ const names = ciMatches.map((c) => `${c.connectionName} (${c.id})`).join(", ");
50
+ throw new Error(
51
+ `Multiple connections match "${trimmed}" by case-insensitive name: ${names}. Pass the exact name or the id.`,
52
+ );
53
+ }
54
+
55
+ // No match — show what's available so the user can pick.
56
+ const available = connections
57
+ .map((c) => c.connectionName || c.name)
58
+ .filter(Boolean)
59
+ .slice(0, 20);
60
+ const hint = available.length
61
+ ? ` Available: ${available.join(", ")}.`
62
+ : " (no connections visible to this token).";
63
+ throw new Error(`Connection "${trimmed}" not found.${hint}`);
64
+ }
65
+
66
+ module.exports = { resolveConnectionId, listConnections };
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ // We mock api/client.request before requiring the resolver so its cached
7
+ // module reference points at our stub.
8
+ const apiClientPath = require.resolve("../api/client");
9
+ const realApiClient = require("../api/client");
10
+
11
+ function withMockedRequest(connections, fn) {
12
+ delete require.cache[require.resolve("./_connections")];
13
+ require.cache[apiClientPath] = {
14
+ ...require.cache[apiClientPath],
15
+ exports: {
16
+ ...realApiClient,
17
+ request: async () => connections,
18
+ },
19
+ };
20
+ try {
21
+ const mod = require("./_connections");
22
+ return fn(mod);
23
+ } finally {
24
+ require.cache[apiClientPath].exports = realApiClient;
25
+ delete require.cache[require.resolve("./_connections")];
26
+ }
27
+ }
28
+
29
+ const session = { baseUrl: "http://x", token: "t" };
30
+
31
+ test("resolves UUID input as-is without a backend roundtrip", async () => {
32
+ await withMockedRequest([], async ({ resolveConnectionId }) => {
33
+ const id = await resolveConnectionId(session, "a273f43a-a844-44a3-9026-1b0de1167e8f");
34
+ assert.equal(id, "a273f43a-a844-44a3-9026-1b0de1167e8f");
35
+ });
36
+ });
37
+
38
+ test("matches by exact connection name", async () => {
39
+ const connections = [
40
+ { id: "id-prod", connectionName: "prod" },
41
+ { id: "id-staging", connectionName: "staging" },
42
+ ];
43
+ await withMockedRequest(connections, async ({ resolveConnectionId }) => {
44
+ assert.equal(await resolveConnectionId(session, "prod"), "id-prod");
45
+ assert.equal(await resolveConnectionId(session, "staging"), "id-staging");
46
+ });
47
+ });
48
+
49
+ test("matches case-insensitively when no exact match exists", async () => {
50
+ const connections = [{ id: "id-prod", connectionName: "MyLocalPG" }];
51
+ await withMockedRequest(connections, async ({ resolveConnectionId }) => {
52
+ assert.equal(await resolveConnectionId(session, "mylocalpg"), "id-prod");
53
+ });
54
+ });
55
+
56
+ test("throws a useful error listing available names when no match", async () => {
57
+ const connections = [
58
+ { id: "1", connectionName: "alpha" },
59
+ { id: "2", connectionName: "beta" },
60
+ ];
61
+ await withMockedRequest(connections, async ({ resolveConnectionId }) => {
62
+ await assert.rejects(
63
+ () => resolveConnectionId(session, "missing"),
64
+ (err) => err.message.includes("alpha") && err.message.includes("beta"),
65
+ );
66
+ });
67
+ });
68
+
69
+ test("requires non-empty input", async () => {
70
+ await withMockedRequest([], async ({ resolveConnectionId }) => {
71
+ await assert.rejects(() => resolveConnectionId(session, ""), /connection name/);
72
+ await assert.rejects(() => resolveConnectionId(session, null), /connection name/);
73
+ });
74
+ });
@@ -3,13 +3,15 @@
3
3
  const os = require("node:os");
4
4
  const { request } = require("../api/client");
5
5
  const { resolveSession } = require("./_session");
6
+ const { resolveConnectionId } = require("./_connections");
6
7
 
7
8
  async function run(opts, { stdout = process.stdout } = {}) {
8
9
  const question = opts.positional.join(" ").trim();
9
- if (!question) throw new Error("Pass a question: `deepsql ask \"why is this query slow?\" --connection <id>`.");
10
- if (!opts.connection) throw new Error("--connection <id> is required.");
10
+ if (!question) throw new Error("Pass a question: `deepsql ask \"why is this query slow?\" --connection <name>`.");
11
+ if (!opts.connection) throw new Error("--connection <name> is required.");
11
12
 
12
13
  const session = resolveSession(opts);
14
+ const connectionId = await resolveConnectionId(session, opts.connection);
13
15
  const userId = opts.user || `cli-${os.userInfo().username}`;
14
16
  const projectId = opts.project || "deepsql-cli";
15
17
 
@@ -18,7 +20,7 @@ async function run(opts, { stdout = process.stdout } = {}) {
18
20
  token: session.token,
19
21
  timeoutMs: 240000,
20
22
  json: {
21
- connectionId: opts.connection,
23
+ connectionId,
22
24
  message: question,
23
25
  chatId: opts.chat || null,
24
26
  userId,
@@ -18,11 +18,23 @@ async function run(opts, { stdout = process.stdout } = {}) {
18
18
  stdout.write("No connections.\n");
19
19
  return;
20
20
  }
21
- for (const conn of data) {
22
- const id = conn.id || conn.connectionId;
23
- const name = conn.connectionName || conn.name || "(unnamed)";
24
- const dbType = conn.databaseType || conn.dbType || "?";
25
- stdout.write(`${id} ${dbType.padEnd(10)} ${name}\n`);
21
+ // Name first it's what users will pass to --connection. ID kept on the
22
+ // right for back-compat / scripting.
23
+ const rows = data.map((conn) => ({
24
+ name: conn.connectionName || conn.name || "(unnamed)",
25
+ type: conn.databaseType || conn.dbType || "?",
26
+ id: conn.id || conn.connectionId || "",
27
+ }));
28
+ const widths = {
29
+ name: Math.max(4, ...rows.map((r) => r.name.length)),
30
+ type: Math.max(4, ...rows.map((r) => r.type.length)),
31
+ };
32
+ stdout.write(
33
+ `${"NAME".padEnd(widths.name)} ${"TYPE".padEnd(widths.type)} ID\n` +
34
+ `${"-".repeat(widths.name)} ${"-".repeat(widths.type)} ${"-".repeat(36)}\n`,
35
+ );
36
+ for (const row of rows) {
37
+ stdout.write(`${row.name.padEnd(widths.name)} ${row.type.padEnd(widths.type)} ${row.id}\n`);
26
38
  }
27
39
  }
28
40
 
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `deepsql digest` — surface the daily DeepSQL digest from the terminal.
5
+ *
6
+ * deepsql digest → show the single most recent digest, full body
7
+ * deepsql digest <N> → list the last N digests (compact, one per row)
8
+ * deepsql digest list [--count N] → same as above; explicit form
9
+ * deepsql digest show <id> → show a specific digest by id
10
+ * --connection <id> → filter to one connection
11
+ * --json → raw JSON output
12
+ *
13
+ * Backend: GET /admin/slack/digests?connectionId=&page=0&size=N
14
+ * Returns a Spring Data Page<SlackDigestLog>: { content: [...], totalElements, ... }
15
+ * Requires ADMIN role on the calling user's MCP token.
16
+ */
17
+
18
+ const { ApiError, request } = require("../api/client");
19
+ const { resolveSession } = require("./_session");
20
+ const { resolveConnectionId } = require("./_connections");
21
+
22
+ const DEFAULT_LIST_COUNT = 10;
23
+ const MAX_COUNT = 100;
24
+
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
+ const session = resolveSession(opts);
32
+ const connectionId = await resolveConnectionId(session, opts.connection);
33
+ const sub = opts.positional[0];
34
+
35
+ // `deepsql digest <N>` shorthand — first positional is a number.
36
+ if (sub && /^\d+$/.test(sub)) {
37
+ return runList(session, connectionId, parseCount(sub), opts, { stdout, stderr });
38
+ }
39
+ if (!sub || sub === "latest") {
40
+ return runLatest(session, connectionId, opts, { stdout, stderr });
41
+ }
42
+ if (sub === "list") {
43
+ const count = parseCount(opts.count) || parseCount(opts.positional[1]) || DEFAULT_LIST_COUNT;
44
+ return runList(session, connectionId, count, opts, { stdout, stderr });
45
+ }
46
+ if (sub === "show") {
47
+ const id = opts.positional[1];
48
+ if (!id) throw new Error("Pass the digest id: `deepsql digest show <id> --connection <name>`.");
49
+ return runShow(session, connectionId, id, opts, { stdout, stderr });
50
+ }
51
+ throw new Error(`Unknown digest subcommand: ${sub}. Try \`latest\`, \`list\`, \`show <id>\`, or pass a number.`);
52
+ }
53
+
54
+ async function runLatest(session, connectionId, opts, { stdout }) {
55
+ const page = await fetchPage(session, connectionId, 0, 1);
56
+ const digest = page.content?.[0];
57
+ if (!digest) {
58
+ stdout.write(`No digests yet for connection "${opts.connection}".\n`);
59
+ return;
60
+ }
61
+ if (opts.json) {
62
+ stdout.write(`${JSON.stringify(digest, null, 2)}\n`);
63
+ return;
64
+ }
65
+ printFull(stdout, digest);
66
+ }
67
+
68
+ async function runList(session, connectionId, requestedCount, opts, { stdout }) {
69
+ const count = Math.min(requestedCount, MAX_COUNT);
70
+ const page = await fetchPage(session, connectionId, 0, count);
71
+ const items = page.content || [];
72
+ if (opts.json) {
73
+ stdout.write(`${JSON.stringify(items, null, 2)}\n`);
74
+ return;
75
+ }
76
+ if (items.length === 0) {
77
+ stdout.write(`No digests yet for connection "${opts.connection}".\n`);
78
+ return;
79
+ }
80
+ printTable(stdout, items);
81
+ if (page.totalElements && page.totalElements > items.length) {
82
+ stdout.write(`\n${items.length} of ${page.totalElements} shown — pass a larger N to see more.\n`);
83
+ }
84
+ }
85
+
86
+ async function runShow(session, connectionId, id, opts, { stdout }) {
87
+ // No single-digest backend endpoint exists yet, so locate by paging.
88
+ // Cheap enough for typical digest counts; we cap at a few pages.
89
+ const target = String(id);
90
+ for (let page = 0; page < 10; page++) {
91
+ const result = await fetchPage(session, connectionId, page, 50);
92
+ const hit = (result.content || []).find((d) => String(d.id) === target);
93
+ if (hit) {
94
+ if (opts.json) {
95
+ stdout.write(`${JSON.stringify(hit, null, 2)}\n`);
96
+ return;
97
+ }
98
+ printFull(stdout, hit);
99
+ return;
100
+ }
101
+ if (result.last || (result.content || []).length === 0) break;
102
+ }
103
+ throw new Error(`Digest ${id} not found in the most recent 500 entries for "${opts.connection}".`);
104
+ }
105
+
106
+ async function fetchPage(session, connectionId, page, size) {
107
+ try {
108
+ return await request(session.baseUrl, "/admin/slack/digests", {
109
+ token: session.token,
110
+ query: {
111
+ connectionId,
112
+ page,
113
+ size,
114
+ },
115
+ });
116
+ } catch (err) {
117
+ if (err instanceof ApiError && err.status === 403) {
118
+ throw new Error(
119
+ "Access denied — fetching digests requires ADMIN role. Ask an administrator to mint a token, or run `deepsql login` as an admin.",
120
+ );
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ function printTable(stdout, items) {
127
+ const rows = items.map((d) => ({
128
+ id: String(d.id ?? ""),
129
+ sentAt: formatTimestamp(d.sentAt),
130
+ status: d.status || "?",
131
+ connection: trim(d.connectionName || d.connectionId || "—", 24),
132
+ headline: trim(d.headline || firstLine(d.content) || "(no headline)", 60),
133
+ }));
134
+ const cols = [
135
+ { key: "id", label: "ID" },
136
+ { key: "sentAt", label: "Sent" },
137
+ { key: "status", label: "Status" },
138
+ { key: "connection", label: "Connection" },
139
+ { key: "headline", label: "Headline" },
140
+ ];
141
+ const widths = cols.map((c) => Math.max(c.label.length, ...rows.map((r) => r[c.key].length)));
142
+ const header = cols.map((c, i) => c.label.padEnd(widths[i])).join(" ");
143
+ const sep = widths.map((w) => "-".repeat(w)).join(" ");
144
+ stdout.write(`${header}\n${sep}\n`);
145
+ for (const row of rows) {
146
+ stdout.write(`${cols.map((c, i) => row[c.key].padEnd(widths[i])).join(" ")}\n`);
147
+ }
148
+ }
149
+
150
+ function printFull(stdout, d) {
151
+ const sent = formatTimestamp(d.sentAt);
152
+ stdout.write(`Digest #${d.id} · ${sent} · ${d.status || "?"}\n`);
153
+ if (d.connectionName || d.connectionId) {
154
+ stdout.write(`Connection: ${d.connectionName || d.connectionId}\n`);
155
+ }
156
+ if (d.headline) {
157
+ stdout.write(`Headline: ${d.headline}\n`);
158
+ }
159
+ stdout.write("\n");
160
+ if (d.status === "FAILED" && d.errorMessage) {
161
+ stdout.write(`Error: ${d.errorMessage}\n`);
162
+ return;
163
+ }
164
+ stdout.write(`${d.content || "(empty)"}\n`);
165
+ }
166
+
167
+ function parseCount(value) {
168
+ if (value == null) return null;
169
+ const n = Number.parseInt(value, 10);
170
+ if (!Number.isFinite(n) || n <= 0) return null;
171
+ return n;
172
+ }
173
+
174
+ function formatTimestamp(value) {
175
+ if (!value) return "—";
176
+ try {
177
+ const d = new Date(value);
178
+ if (Number.isNaN(d.getTime())) return String(value);
179
+ return d.toISOString().replace("T", " ").slice(0, 16) + "Z";
180
+ } catch {
181
+ return String(value);
182
+ }
183
+ }
184
+
185
+ function trim(text, maxLen) {
186
+ if (!text) return "";
187
+ const s = String(text).replace(/\s+/g, " ").trim();
188
+ return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s;
189
+ }
190
+
191
+ function firstLine(text) {
192
+ if (!text) return "";
193
+ const idx = text.indexOf("\n");
194
+ return idx === -1 ? text : text.slice(0, idx);
195
+ }
196
+
197
+ module.exports = { run };
@@ -4,19 +4,21 @@ const fs = require("node:fs");
4
4
  const { request } = require("../api/client");
5
5
  const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
6
6
  const { resolveSession } = require("./_session");
7
+ const { resolveConnectionId } = require("./_connections");
7
8
 
8
9
  async function run(opts, { stdout = process.stdout } = {}) {
9
- if (!opts.connection) throw new Error("--connection <id> is required.");
10
+ if (!opts.connection) throw new Error("--connection <name> is required.");
10
11
  const sql = readSqlInput(opts);
11
12
  // EXPLAIN ANALYZE is mutating; require plain EXPLAIN.
12
13
  const validation = validateReadOnlySql(sql, { allowExplain: false });
13
14
  if (!validation.ok) throw new Error(validation.reason);
14
15
 
15
16
  const session = resolveSession(opts);
17
+ const connectionId = await resolveConnectionId(session, opts.connection);
16
18
  const response = await request(session.baseUrl, "/mcp/explain-readonly", {
17
19
  method: "POST",
18
20
  token: session.token,
19
- json: { connectionId: opts.connection, query: validation.normalizedQuery },
21
+ json: { connectionId, query: validation.normalizedQuery },
20
22
  });
21
23
 
22
24
  if (opts.json) {
@@ -3,6 +3,7 @@
3
3
  const os = require("node:os");
4
4
  const { runBrowserFlow } = require("../auth/browser-flow");
5
5
  const { runDeviceFlow } = require("../auth/device-flow");
6
+ const { runPasswordFlow } = require("../auth/password-flow");
6
7
  const store = require("../auth/store");
7
8
  const { ApiError } = require("../api/client");
8
9
 
@@ -10,13 +11,15 @@ function defaultClientLabel() {
10
11
  return `deepsql-cli@${process.versions.node}`;
11
12
  }
12
13
 
13
- function shouldUseDevice(opts) {
14
- if (opts.device) return true;
15
- if (opts.browser) return false;
16
- // Heuristic: assume browser unless we detect a headless env.
17
- if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return true;
18
- if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
19
- return false;
14
+ function pickFlow(opts) {
15
+ // Explicit user choice wins.
16
+ if (opts.password) return "password";
17
+ if (opts.device) return "device";
18
+ if (opts.browser) return "browser";
19
+ // Heuristic: prefer browser; fall back to device-code in headless envs.
20
+ if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return "device";
21
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return "device";
22
+ return "browser";
20
23
  }
21
24
 
22
25
  async function run(opts, { stderr = process.stderr, stdout = process.stdout } = {}) {
@@ -28,14 +31,24 @@ async function run(opts, { stderr = process.stderr, stdout = process.stdout } =
28
31
  }
29
32
  const hostname = os.hostname();
30
33
  const label = opts.label || defaultClientLabel();
31
- const useDevice = shouldUseDevice(opts);
34
+ const flow = pickFlow(opts);
32
35
  const log = (msg) => stderr.write(`[deepsql] ${msg}\n`);
33
36
 
34
37
  let issued;
35
38
  try {
36
- if (useDevice) {
39
+ if (flow === "device") {
37
40
  log(`Starting device-code login against ${baseUrl}…`);
38
41
  issued = await runDeviceFlow({ baseUrl, hostname, clientLabel: label, log });
42
+ } else if (flow === "password") {
43
+ log(`Starting password login against ${baseUrl}…`);
44
+ issued = await runPasswordFlow({
45
+ baseUrl,
46
+ email: opts.email,
47
+ passwordStdin: !!opts.passwordStdin,
48
+ hostname,
49
+ clientLabel: label,
50
+ log,
51
+ });
39
52
  } else {
40
53
  log(`Starting browser login against ${baseUrl}…`);
41
54
  issued = await runBrowserFlow({
@@ -4,14 +4,16 @@ const fs = require("node:fs");
4
4
  const { request } = require("../api/client");
5
5
  const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
6
6
  const { resolveSession } = require("./_session");
7
+ const { resolveConnectionId } = require("./_connections");
7
8
 
8
9
  async function run(opts, { stdout = process.stdout } = {}) {
9
- if (!opts.connection) throw new Error("--connection <id> is required.");
10
+ if (!opts.connection) throw new Error("--connection <name> is required.");
10
11
  const sql = readSqlInput(opts);
11
12
  const validation = validateReadOnlySql(sql, { allowExplain: true });
12
13
  if (!validation.ok) throw new Error(validation.reason);
13
14
 
14
15
  const session = resolveSession(opts);
16
+ const connectionId = await resolveConnectionId(session, opts.connection);
15
17
  const limit = clampInt(opts.limit, 1, 1000, 100);
16
18
  const timeout = opts.timeoutSeconds == null ? null : clampInt(opts.timeoutSeconds, 1, 60, null);
17
19
 
@@ -19,7 +21,7 @@ async function run(opts, { stdout = process.stdout } = {}) {
19
21
  method: "POST",
20
22
  token: session.token,
21
23
  json: {
22
- connectionId: opts.connection,
24
+ connectionId,
23
25
  query: validation.normalizedQuery,
24
26
  limit,
25
27
  timeoutSeconds: timeout,
@@ -48,19 +50,49 @@ function clampInt(value, min, max, fallback) {
48
50
  }
49
51
 
50
52
  function printRows(stdout, response) {
51
- const rows = response?.rows || response?.data || [];
52
- const columns = response?.columns || (rows[0] ? Object.keys(rows[0]) : []);
53
- if (columns.length === 0) {
53
+ // Backend returns: { result: { columns: [...], rows: [[v1,v2,...], ...],
54
+ // rowCount, totalRowCount, isLimited, ... },
55
+ // success, queryType }
56
+ // Tolerate older shapes too — `rows` directly on the response, with rows as
57
+ // either arrays or row-objects.
58
+ const result = response?.result ?? response ?? {};
59
+ const rawRows = result.rows ?? result.data ?? [];
60
+ let columns = result.columns;
61
+ if (!Array.isArray(columns) || columns.length === 0) {
62
+ columns = rawRows[0] && !Array.isArray(rawRows[0]) ? Object.keys(rawRows[0]) : [];
63
+ }
64
+ if (columns.length === 0 || rawRows.length === 0) {
54
65
  stdout.write("(no rows)\n");
55
66
  return;
56
67
  }
57
- const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? "").length)));
68
+ const cellAt = (row, idx, col) =>
69
+ Array.isArray(row) ? row[idx] : row?.[col];
70
+ const widths = columns.map((c, i) =>
71
+ Math.max(
72
+ String(c).length,
73
+ ...rawRows.map((r) => String(cellAt(r, i, c) ?? "").length),
74
+ ),
75
+ );
58
76
  const sep = widths.map((w) => "-".repeat(w)).join(" ");
59
- stdout.write(`${columns.map((c, i) => c.padEnd(widths[i])).join(" ")}\n${sep}\n`);
60
- for (const row of rows) {
61
- stdout.write(`${columns.map((c, i) => String(row[c] ?? "").padEnd(widths[i])).join(" ")}\n`);
77
+ stdout.write(
78
+ `${columns.map((c, i) => String(c).padEnd(widths[i])).join(" ")}\n${sep}\n`,
79
+ );
80
+ for (const row of rawRows) {
81
+ stdout.write(
82
+ `${columns
83
+ .map((c, i) => String(cellAt(row, i, c) ?? "").padEnd(widths[i]))
84
+ .join(" ")}\n`,
85
+ );
86
+ }
87
+ if (result.isLimited || result.truncated) {
88
+ const shown = rawRows.length;
89
+ const total = result.totalRowCount;
90
+ stdout.write(
91
+ total != null && total > shown
92
+ ? `(showing ${shown} of ${total} rows)\n`
93
+ : `(result limited to ${shown} rows)\n`,
94
+ );
62
95
  }
63
- if (response?.truncated) stdout.write(`(truncated to ${rows.length} rows)\n`);
64
96
  }
65
97
 
66
98
  module.exports = { run };
@@ -2,15 +2,17 @@
2
2
 
3
3
  const { request } = require("../api/client");
4
4
  const { resolveSession } = require("./_session");
5
+ const { resolveConnectionId } = require("./_connections");
5
6
 
6
7
  async function run(opts, { stdout = process.stdout } = {}) {
7
- if (!opts.connection) throw new Error("--connection <id> is required.");
8
+ if (!opts.connection) throw new Error("--connection <name> is required.");
8
9
  const session = resolveSession(opts);
10
+ const connectionId = await resolveConnectionId(session, opts.connection);
9
11
  const sub = opts.positional[0] || "tables";
10
12
  const path =
11
13
  sub === "objects"
12
- ? `/connections/${encodeURIComponent(opts.connection)}/objects`
13
- : `/connections/${encodeURIComponent(opts.connection)}/schema`;
14
+ ? `/connections/${encodeURIComponent(connectionId)}/objects`
15
+ : `/connections/${encodeURIComponent(connectionId)}/schema`;
14
16
  const response = await request(session.baseUrl, path, { token: session.token });
15
17
  stdout.write(`${JSON.stringify(response, null, 2)}\n`);
16
18
  }