@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 +5 -2
- package/src/cli.js +78 -6
- package/src/commands/_connections.js +66 -0
- package/src/commands/_connections.test.js +74 -0
- 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/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/permissions.js +149 -0
- package/src/commands/query.js +42 -10
- package/src/commands/schema.js +5 -3
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepsql/mcp",
|
|
3
|
-
"version": "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 <
|
|
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 <
|
|
55
|
+
ask "<question>" --connection <name> [--chat <id>] [--json]
|
|
50
56
|
Ask DeepSQL a question.
|
|
51
|
-
query "<sql>" --connection <
|
|
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 <
|
|
59
|
+
explain "<sql>" --connection <name> [--file <path>] [--json]
|
|
54
60
|
Get an EXPLAIN plan.
|
|
55
|
-
schema [tables|objects] --connection <
|
|
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
|
|
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 } };
|