@deepsql/mcp 0.3.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 +1 -1
- package/src/cli.js +14 -5
- package/src/commands/_connections.js +66 -0
- package/src/commands/_connections.test.js +74 -0
- package/src/commands/ask.js +5 -3
- package/src/commands/connections.js +17 -5
- package/src/commands/digest.js +197 -0
- package/src/commands/explain.js +4 -2
- package/src/commands/query.js +42 -10
- package/src/commands/schema.js +5 -3
package/package.json
CHANGED
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 <
|
|
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
|
|
@@ -46,14 +47,21 @@ Commands:
|
|
|
46
47
|
config path Print the auth file path.
|
|
47
48
|
mcp Run the stdio MCP server using the saved token.
|
|
48
49
|
connections list [--json] List database connections.
|
|
49
|
-
ask "<question>" --connection <
|
|
50
|
+
ask "<question>" --connection <name> [--chat <id>] [--json]
|
|
50
51
|
Ask DeepSQL a question.
|
|
51
|
-
query "<sql>" --connection <
|
|
52
|
+
query "<sql>" --connection <name> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
|
|
52
53
|
Run a read-only SQL query.
|
|
53
|
-
explain "<sql>" --connection <
|
|
54
|
+
explain "<sql>" --connection <name> [--file <path>] [--json]
|
|
54
55
|
Get an EXPLAIN plan.
|
|
55
|
-
schema [tables|objects] --connection <
|
|
56
|
+
schema [tables|objects] --connection <name>
|
|
56
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.
|
|
57
65
|
|
|
58
66
|
Global options:
|
|
59
67
|
--url <url> Override the DeepSQL base URL.
|
|
@@ -122,6 +130,7 @@ function buildOpts(parsed) {
|
|
|
122
130
|
user: f.user || null,
|
|
123
131
|
project: f.project || null,
|
|
124
132
|
limit: f.limit,
|
|
133
|
+
count: f.count || f.n || null,
|
|
125
134
|
timeoutSeconds: f.timeoutSeconds,
|
|
126
135
|
file: f.file || null,
|
|
127
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
|
+
});
|
package/src/commands/ask.js
CHANGED
|
@@ -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 <
|
|
10
|
-
if (!opts.connection) throw new Error("--connection <
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 };
|
package/src/commands/explain.js
CHANGED
|
@@ -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 <
|
|
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
|
|
21
|
+
json: { connectionId, query: validation.normalizedQuery },
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
if (opts.json) {
|
package/src/commands/query.js
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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(
|
|
60
|
-
|
|
61
|
-
|
|
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 };
|
package/src/commands/schema.js
CHANGED
|
@@ -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 <
|
|
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(
|
|
13
|
-
: `/connections/${encodeURIComponent(
|
|
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
|
}
|