@deepsql/mcp 0.3.0 → 0.6.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.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "./bin/deepsql.js",
@@ -22,5 +22,8 @@
22
22
  "engines": {
23
23
  "node": ">=20"
24
24
  },
25
- "license": "UNLICENSED"
25
+ "license": "UNLICENSED",
26
+ "dependencies": {
27
+ "@inquirer/prompts": "^8.4.2"
28
+ }
26
29
  }
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,12 @@ 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"),
27
+ users: () => require("./commands/users"),
28
+ access: () => require("./commands/access"),
29
+ permissions: () => require("./commands/permissions"),
30
+ "slow-queries": () => require("./commands/slow-queries"),
31
+ setup: () => require("./commands/setup"),
26
32
  };
27
33
 
28
34
  const HELP = `deepsql — DeepSQL CLI
@@ -46,14 +52,50 @@ Commands:
46
52
  config path Print the auth file path.
47
53
  mcp Run the stdio MCP server using the saved token.
48
54
  connections list [--json] List database connections.
49
- ask "<question>" --connection <id> [--chat <id>] [--json]
55
+ ask "<question>" --connection <name> [--chat <id>] [--json]
50
56
  Ask DeepSQL a question.
51
- query "<sql>" --connection <id> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
57
+ query "<sql>" --connection <name> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
52
58
  Run a read-only SQL query.
53
- explain "<sql>" --connection <id> [--file <path>] [--json]
59
+ explain "<sql>" --connection <name> [--file <path>] [--json]
54
60
  Get an EXPLAIN plan.
55
- schema [tables|objects] --connection <id>
61
+ schema [tables|objects] --connection <name>
56
62
  Dump schema or database objects as JSON.
63
+ digest [N] [--connection <name>] [--json]
64
+ Show the latest DeepSQL digest, or pass a
65
+ number to list the last N (e.g. digest 5).
66
+ digest list [--count N] [--connection <name>] [--json]
67
+ Explicit list form (same as digest <N>).
68
+ digest show <id> [--connection <name>] [--json]
69
+ Show one digest by id.
70
+
71
+ Admin commands (require ADMIN role on the calling token):
72
+ users list | get <ref> | add [<email>] [--role <r>] [--name <n>] [--password-stdin]
73
+ | set-role <ref> <role> | lock|unlock|disable <ref>
74
+ | resend-invite <ref> | reset-password <ref> [--password-stdin]
75
+ | delete <ref> [--yes]
76
+ Manage workspace users.
77
+ access list --user <ref> | --connection <name>
78
+ | grant --user <ref> --connection <name> --level read|write|admin
79
+ | revoke --user <ref> --connection <name>
80
+ | policy <user> <connection> (opens $EDITOR)
81
+ Per-connection access grants and chat policies.
82
+ permissions list [--role <ROLE>] [--json]
83
+ | override --role <ROLE> --permission <PERM> --grant|--revoke [--reason "..."]
84
+ | reset --role <ROLE> --permission <PERM>
85
+ Role-based permission overrides.
86
+ slow-queries latest --connection <name>
87
+ | history --connection <name> [N]
88
+ | analyze --connection <name> [--time-range LAST_24_HOURS|LAST_HOUR]
89
+ [--threshold-ms <n>] [--limit <n>]
90
+ | optimize --connection <name> --query-id <id>
91
+ (streams AI optimization steps to stderr; result to stdout)
92
+ | delete (--history-id <id> | --connection <name>) [--yes]
93
+ Read, trigger, and clean up slow-query analyses.
94
+ setup [--skip-email] [--skip-slack] [--skip-complete]
95
+ Post-install wizard: SMTP/email, Slack
96
+ (digests + bot), then mark setup complete.
97
+ Org and LLM config are set at install time
98
+ and are NOT touched by this wizard.
57
99
 
58
100
  Global options:
59
101
  --url <url> Override the DeepSQL base URL.
@@ -110,20 +152,50 @@ function buildOpts(parsed) {
110
152
  url: f.url || null,
111
153
  token: f.token || null,
112
154
  json: !!f.json,
155
+ // Login-flow selectors
113
156
  device: !!f.device,
114
157
  browser: !!f.browser,
115
158
  noBrowser: !!f.noBrowser,
116
- password: !!f.password,
159
+ // password may be `true` (login flow flag) OR a string value (`users add
160
+ // --password secret`, `users add --password=secret`). Each command
161
+ // interprets whichever shape it expects.
162
+ password: f.password ?? null,
117
163
  passwordStdin: !!f.passwordStdin,
118
164
  email: f.email || null,
119
165
  label: f.label || null,
166
+ // Connection / users / chat
120
167
  connection: f.connection || f.c || null,
121
168
  chat: f.chat || null,
122
169
  user: f.user || null,
123
170
  project: f.project || null,
171
+ name: f.name || null,
172
+ username: f.username || null,
173
+ role: f.role || null,
174
+ // List / pagination
124
175
  limit: f.limit,
176
+ count: f.count || f.n || null,
125
177
  timeoutSeconds: f.timeoutSeconds,
126
178
  file: f.file || null,
179
+ // RBAC / access
180
+ level: f.level || null,
181
+ permission: f.permission || null,
182
+ grant: !!f.grant,
183
+ revoke: !!f.revoke,
184
+ reason: f.reason || null,
185
+ // Slow queries
186
+ timeRange: f.timeRange || null,
187
+ thresholdMs: f.thresholdMs || null,
188
+ queryId: f.queryId || null,
189
+ queryText: f.queryText || null,
190
+ sampleQuery: f.sampleQuery || null,
191
+ historyId: f.historyId || null,
192
+ // Setup wizard
193
+ force: !!f.force,
194
+ skipEmail: !!f.skipEmail,
195
+ skipSlack: !!f.skipSlack,
196
+ skipComplete: !!f.skipComplete,
197
+ // Confirmations
198
+ yes: !!f.yes || !!f.y,
127
199
  };
128
200
  }
129
201
 
@@ -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
+ });
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Resolve a user reference (numeric id, email, or username) to a {id, email,
5
+ * username, role, ...} record.
6
+ *
7
+ * Backend `GET /admin/users` returns the full list, so we fetch once per
8
+ * invocation and match locally. Cheap for typical org sizes.
9
+ */
10
+
11
+ const { request } = require("../api/client");
12
+
13
+ let cachedUsers = null;
14
+
15
+ async function listUsers(session) {
16
+ if (cachedUsers) return cachedUsers;
17
+ cachedUsers = await request(session.baseUrl, "/admin/users", { token: session.token });
18
+ if (!Array.isArray(cachedUsers)) cachedUsers = [];
19
+ return cachedUsers;
20
+ }
21
+
22
+ function clearUserCache() {
23
+ cachedUsers = null;
24
+ }
25
+
26
+ async function resolveUser(session, ref) {
27
+ if (ref == null || String(ref).trim() === "") {
28
+ throw new Error("Pass a user email, username, or numeric id.");
29
+ }
30
+ const trimmed = String(ref).trim();
31
+
32
+ // Numeric id — short-circuit if list isn't already cached.
33
+ if (/^\d+$/.test(trimmed)) {
34
+ const users = await listUsers(session);
35
+ const hit = users.find((u) => String(u.id) === trimmed);
36
+ if (hit) return hit;
37
+ throw new Error(`User id ${trimmed} not found.`);
38
+ }
39
+
40
+ const users = await listUsers(session);
41
+ const lower = trimmed.toLowerCase();
42
+ const exactEmail = users.find((u) => (u.email || "").toLowerCase() === lower);
43
+ if (exactEmail) return exactEmail;
44
+ const exactUsername = users.find((u) => (u.username || "").toLowerCase() === lower);
45
+ if (exactUsername) return exactUsername;
46
+
47
+ const available = users
48
+ .map((u) => u.email || u.username)
49
+ .filter(Boolean)
50
+ .slice(0, 20);
51
+ const hint = available.length ? ` Available: ${available.join(", ")}.` : "";
52
+ throw new Error(`User "${trimmed}" not found.${hint}`);
53
+ }
54
+
55
+ module.exports = { listUsers, resolveUser, clearUserCache };
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `deepsql access` — per-connection access grants and chat-policy editing.
5
+ *
6
+ * deepsql access list --user <ref> → connections this user can see
7
+ * deepsql access list --connection <name> → users who can see this connection
8
+ * deepsql access grant --user <ref> --connection <name> --level read|write|admin
9
+ * deepsql access revoke --user <ref> --connection <name>
10
+ * deepsql access policy <user> <connection> → opens $EDITOR with the
11
+ * plain-English chat policy; on save, validates via the preview endpoint
12
+ * and PUTs the result.
13
+ */
14
+
15
+ const { ApiError, request } = require("../api/client");
16
+ const { resolveSession } = require("./_session");
17
+ const { resolveUser } = require("./_users");
18
+ const { resolveConnectionId, listConnections } = require("./_connections");
19
+ const { editText } = require("../ui/editor");
20
+
21
+ const SUBCOMMANDS = {
22
+ list: cmdList,
23
+ grant: cmdGrant,
24
+ revoke: cmdRevoke,
25
+ policy: cmdPolicy,
26
+ };
27
+
28
+ async function run(opts, io = {}) {
29
+ const sub = opts.positional[0];
30
+ if (!sub) throw new Error("Usage: deepsql access <list|grant|revoke|policy> ...");
31
+ const handler = SUBCOMMANDS[sub];
32
+ if (!handler) throw new Error(`Unknown access subcommand: ${sub}.`);
33
+ return wrap(handler)(
34
+ { ...opts, positional: opts.positional.slice(1) },
35
+ io,
36
+ );
37
+ }
38
+
39
+ function wrap(handler) {
40
+ return async (opts, io) => {
41
+ try {
42
+ return await handler(opts, io);
43
+ } catch (err) {
44
+ if (err instanceof ApiError && err.status === 403) {
45
+ throw new Error("Access denied — managing access requires ADMIN role.");
46
+ }
47
+ throw err;
48
+ }
49
+ };
50
+ }
51
+
52
+ // ─── list ──────────────────────────────────────────────────────────────────
53
+
54
+ async function cmdList(opts, { stdout = process.stdout } = {}) {
55
+ const session = resolveSession(opts);
56
+ if (opts.user) {
57
+ const user = await resolveUser(session, opts.user);
58
+ const grants = await request(session.baseUrl, `/admin/users/${user.id}/connection-access`, {
59
+ token: session.token,
60
+ });
61
+ if (opts.json) {
62
+ stdout.write(`${JSON.stringify(grants, null, 2)}\n`);
63
+ return;
64
+ }
65
+ if (!Array.isArray(grants) || grants.length === 0) {
66
+ stdout.write(`${user.email || user.username} has no connection grants.\n`);
67
+ return;
68
+ }
69
+ printGrants(stdout, grants, "user");
70
+ return;
71
+ }
72
+ if (opts.connection) {
73
+ const connectionId = await resolveConnectionId(session, opts.connection);
74
+ const grants = await request(
75
+ session.baseUrl,
76
+ `/admin/connections/${encodeURIComponent(connectionId)}/connection-access`,
77
+ { token: session.token },
78
+ );
79
+ if (opts.json) {
80
+ stdout.write(`${JSON.stringify(grants, null, 2)}\n`);
81
+ return;
82
+ }
83
+ if (!Array.isArray(grants) || grants.length === 0) {
84
+ stdout.write(`No grants on ${opts.connection}.\n`);
85
+ return;
86
+ }
87
+ printGrants(stdout, grants, "connection");
88
+ return;
89
+ }
90
+ throw new Error("Pass --user <ref> or --connection <name>.");
91
+ }
92
+
93
+ // ─── grant ─────────────────────────────────────────────────────────────────
94
+
95
+ async function cmdGrant(opts, { stdout = process.stdout } = {}) {
96
+ if (!opts.user) throw new Error("--user <ref> is required.");
97
+ if (!opts.connection) throw new Error("--connection <name> is required.");
98
+ const level = (opts.level || "READ").toUpperCase();
99
+ if (!["READ", "WRITE", "ADMIN"].includes(level)) {
100
+ throw new Error(`Invalid --level "${level}". Pick read, write, or admin.`);
101
+ }
102
+
103
+ const session = resolveSession(opts);
104
+ const user = await resolveUser(session, opts.user);
105
+ const connectionId = await resolveConnectionId(session, opts.connection);
106
+
107
+ await request(
108
+ session.baseUrl,
109
+ `/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}`,
110
+ {
111
+ method: "PUT",
112
+ token: session.token,
113
+ json: { accessLevel: level },
114
+ },
115
+ );
116
+ stdout.write(
117
+ `Granted ${level} on ${opts.connection} to ${user.email || user.username}.\n`,
118
+ );
119
+ }
120
+
121
+ // ─── revoke ────────────────────────────────────────────────────────────────
122
+
123
+ async function cmdRevoke(opts, { stdout = process.stdout } = {}) {
124
+ if (!opts.user) throw new Error("--user <ref> is required.");
125
+ if (!opts.connection) throw new Error("--connection <name> is required.");
126
+
127
+ const session = resolveSession(opts);
128
+ const user = await resolveUser(session, opts.user);
129
+ const connectionId = await resolveConnectionId(session, opts.connection);
130
+
131
+ await request(
132
+ session.baseUrl,
133
+ `/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}`,
134
+ { method: "DELETE", token: session.token },
135
+ );
136
+ stdout.write(`Revoked ${opts.connection} for ${user.email || user.username}.\n`);
137
+ }
138
+
139
+ // ─── policy ────────────────────────────────────────────────────────────────
140
+
141
+ const POLICY_HEADER =
142
+ "# Plain-English chat access policy. Lines starting with # are kept as-is —\n" +
143
+ "# DeepSQL doesn't strip them. Save and quit to commit; quit without changes\n" +
144
+ "# (e.g. :cq in vi) to abort.";
145
+
146
+ async function cmdPolicy(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
147
+ const userRef = opts.positional[0];
148
+ const connRef = opts.positional[1];
149
+ if (!userRef || !connRef) {
150
+ throw new Error("Usage: deepsql access policy <user> <connection>");
151
+ }
152
+
153
+ const session = resolveSession(opts);
154
+ const user = await resolveUser(session, userRef);
155
+ const connectionId = await resolveConnectionId(session, connRef);
156
+
157
+ const existing = await request(
158
+ session.baseUrl,
159
+ `/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}/chat-policy`,
160
+ { token: session.token },
161
+ );
162
+
163
+ const initial = (existing && existing.plainEnglishPolicy) || "";
164
+
165
+ stderr.write(
166
+ `Editing policy for ${user.email || user.username} on ${connRef}…\n`,
167
+ );
168
+ const { content, changed } = await editText(initial, {
169
+ suffix: ".policy.md",
170
+ header: POLICY_HEADER,
171
+ });
172
+
173
+ if (!changed) {
174
+ stderr.write("No changes.\n");
175
+ return;
176
+ }
177
+
178
+ // Validate via preview before committing.
179
+ const preview = await request(session.baseUrl, "/admin/connection-chat-policies/preview", {
180
+ method: "POST",
181
+ token: session.token,
182
+ json: { connectionId, plainEnglishPolicy: content },
183
+ });
184
+ if (preview && preview.error) {
185
+ throw new Error(`Policy preview rejected: ${preview.error}`);
186
+ }
187
+
188
+ const saved = await request(
189
+ session.baseUrl,
190
+ `/admin/users/${user.id}/connection-access/${encodeURIComponent(connectionId)}/chat-policy`,
191
+ {
192
+ method: "PUT",
193
+ token: session.token,
194
+ json: { plainEnglishPolicy: content, active: true },
195
+ },
196
+ );
197
+ stdout.write(`Saved policy for ${user.email || user.username} on ${connRef}.\n`);
198
+ if (opts.json) {
199
+ stdout.write(`${JSON.stringify(saved, null, 2)}\n`);
200
+ }
201
+ }
202
+
203
+ // ─── helpers ───────────────────────────────────────────────────────────────
204
+
205
+ function printGrants(stdout, grants, mode) {
206
+ const rows = grants.map((g) => ({
207
+ a: mode === "user" ? (g.connectionName || g.connectionId || "") : (g.email || g.username || ""),
208
+ level: (g.accessLevel || g.level || "").toUpperCase(),
209
+ grantedBy: g.grantedBy || "",
210
+ }));
211
+ const headerA = mode === "user" ? "CONNECTION" : "USER";
212
+ const widthA = Math.max(headerA.length, ...rows.map((r) => r.a.length));
213
+ const widthLevel = Math.max("LEVEL".length, ...rows.map((r) => r.level.length));
214
+ const widthGrantedBy = Math.max("GRANTED BY".length, ...rows.map((r) => r.grantedBy.length));
215
+ stdout.write(
216
+ `${headerA.padEnd(widthA)} ${"LEVEL".padEnd(widthLevel)} ${"GRANTED BY".padEnd(widthGrantedBy)}\n` +
217
+ `${"-".repeat(widthA)} ${"-".repeat(widthLevel)} ${"-".repeat(widthGrantedBy)}\n`,
218
+ );
219
+ for (const r of rows) {
220
+ stdout.write(`${r.a.padEnd(widthA)} ${r.level.padEnd(widthLevel)} ${r.grantedBy.padEnd(widthGrantedBy)}\n`);
221
+ }
222
+ }
223
+
224
+ // listConnections used in __mocks elsewhere; expose for tests if needed
225
+ module.exports = { run, _internal: { listConnections } };