@deepsql/mcp 0.2.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/README.md +42 -0
- package/bin/deepsql.js +13 -0
- package/claude_desktop_config.customer.example.json +17 -0
- package/codex_config.customer.example.toml +9 -0
- package/deepsql-phase1-lib.js +586 -0
- package/deepsql-phase1-server.js +280 -0
- package/package.json +26 -0
- package/src/api/client.js +93 -0
- package/src/auth/browser-flow.js +152 -0
- package/src/auth/device-flow.js +59 -0
- package/src/auth/pkce.js +41 -0
- package/src/auth/pkce.test.js +32 -0
- package/src/auth/store.js +152 -0
- package/src/auth/store.test.js +78 -0
- package/src/cli.js +158 -0
- package/src/cli.test.js +38 -0
- package/src/commands/_session.js +41 -0
- package/src/commands/ask.js +42 -0
- package/src/commands/config.js +42 -0
- package/src/commands/connections.js +29 -0
- package/src/commands/explain.js +40 -0
- package/src/commands/login.js +68 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +31 -0
- package/src/commands/query.js +66 -0
- package/src/commands/schema.js +18 -0
- package/src/commands/whoami.js +23 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistent CLI auth store.
|
|
5
|
+
*
|
|
6
|
+
* Layout (XDG-friendly):
|
|
7
|
+
* ~/.config/deepsql/auth.json (Linux/macOS)
|
|
8
|
+
* %APPDATA%\deepsql\auth.json (Windows)
|
|
9
|
+
*
|
|
10
|
+
* File format:
|
|
11
|
+
* {
|
|
12
|
+
* "default": "http://localhost:8080",
|
|
13
|
+
* "profiles": {
|
|
14
|
+
* "<base-url>": { token, username, tokenId, createdAt }
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* The file is written with mode 0600 and the parent dir with 0700. We refuse to
|
|
19
|
+
* read a file with looser perms unless DEEPSQL_INSECURE_AUTH=1 is set, since
|
|
20
|
+
* tokens grant access to the user's databases.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require("node:fs");
|
|
24
|
+
const path = require("node:path");
|
|
25
|
+
const os = require("node:os");
|
|
26
|
+
|
|
27
|
+
function configDir() {
|
|
28
|
+
const override = process.env.DEEPSQL_CONFIG_DIR;
|
|
29
|
+
if (override) return override;
|
|
30
|
+
if (process.platform === "win32") {
|
|
31
|
+
const base = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
32
|
+
return path.join(base, "deepsql");
|
|
33
|
+
}
|
|
34
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
35
|
+
if (xdg) return path.join(xdg, "deepsql");
|
|
36
|
+
return path.join(os.homedir(), ".config", "deepsql");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function authFilePath() {
|
|
40
|
+
return path.join(configDir(), "auth.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeBaseUrl(url) {
|
|
44
|
+
if (!url || typeof url !== "string") return url;
|
|
45
|
+
return url.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptyState() {
|
|
49
|
+
return { default: null, profiles: {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function load() {
|
|
53
|
+
const file = authFilePath();
|
|
54
|
+
if (!fs.existsSync(file)) {
|
|
55
|
+
return emptyState();
|
|
56
|
+
}
|
|
57
|
+
if (process.platform !== "win32" && process.env.DEEPSQL_INSECURE_AUTH !== "1") {
|
|
58
|
+
const stat = fs.statSync(file);
|
|
59
|
+
// Mode 0600 == 0o600. Reject if group/other have any bits set.
|
|
60
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${file} has insecure permissions (${(stat.mode & 0o777).toString(8)}). ` +
|
|
63
|
+
"Run `chmod 600` on it, or set DEEPSQL_INSECURE_AUTH=1 to override.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
69
|
+
if (!parsed || typeof parsed !== "object") return emptyState();
|
|
70
|
+
return {
|
|
71
|
+
default: parsed.default || null,
|
|
72
|
+
profiles: parsed.profiles || {},
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw new Error(`Failed to parse ${file}: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function save(state) {
|
|
80
|
+
const dir = configDir();
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
if (process.platform !== "win32") {
|
|
83
|
+
try {
|
|
84
|
+
fs.chmodSync(dir, 0o700);
|
|
85
|
+
} catch {
|
|
86
|
+
// Best-effort — some filesystems (FAT, network mounts) don't support chmod.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const file = authFilePath();
|
|
90
|
+
const tmp = `${file}.tmp`;
|
|
91
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
92
|
+
fs.renameSync(tmp, file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getProfile(baseUrl) {
|
|
96
|
+
const state = load();
|
|
97
|
+
const key = normalizeBaseUrl(baseUrl) || state.default;
|
|
98
|
+
if (!key) return null;
|
|
99
|
+
return state.profiles[key] || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setProfile(baseUrl, profile) {
|
|
103
|
+
const state = load();
|
|
104
|
+
const key = normalizeBaseUrl(baseUrl);
|
|
105
|
+
state.profiles[key] = profile;
|
|
106
|
+
if (!state.default) state.default = key;
|
|
107
|
+
save(state);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function removeProfile(baseUrl) {
|
|
111
|
+
const state = load();
|
|
112
|
+
const key = normalizeBaseUrl(baseUrl);
|
|
113
|
+
delete state.profiles[key];
|
|
114
|
+
if (state.default === key) {
|
|
115
|
+
const remaining = Object.keys(state.profiles);
|
|
116
|
+
state.default = remaining[0] || null;
|
|
117
|
+
}
|
|
118
|
+
save(state);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setDefault(baseUrl) {
|
|
122
|
+
const state = load();
|
|
123
|
+
const key = normalizeBaseUrl(baseUrl);
|
|
124
|
+
if (!state.profiles[key]) {
|
|
125
|
+
throw new Error(`No profile saved for ${key}. Run \`deepsql login --url ${key}\` first.`);
|
|
126
|
+
}
|
|
127
|
+
state.default = key;
|
|
128
|
+
save(state);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultBaseUrl() {
|
|
132
|
+
const state = load();
|
|
133
|
+
return state.default || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listProfiles() {
|
|
137
|
+
return load();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
authFilePath,
|
|
142
|
+
configDir,
|
|
143
|
+
defaultBaseUrl,
|
|
144
|
+
getProfile,
|
|
145
|
+
listProfiles,
|
|
146
|
+
load,
|
|
147
|
+
normalizeBaseUrl,
|
|
148
|
+
removeProfile,
|
|
149
|
+
save,
|
|
150
|
+
setDefault,
|
|
151
|
+
setProfile,
|
|
152
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
function withTempStore(fn) {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsql-store-"));
|
|
11
|
+
const previous = process.env.DEEPSQL_CONFIG_DIR;
|
|
12
|
+
process.env.DEEPSQL_CONFIG_DIR = dir;
|
|
13
|
+
// Force re-resolution of the path each call.
|
|
14
|
+
delete require.cache[require.resolve("./store")];
|
|
15
|
+
const store = require("./store");
|
|
16
|
+
try {
|
|
17
|
+
return fn(store, dir);
|
|
18
|
+
} finally {
|
|
19
|
+
process.env.DEEPSQL_CONFIG_DIR = previous;
|
|
20
|
+
delete require.cache[require.resolve("./store")];
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("setProfile then getProfile round-trips", () => {
|
|
26
|
+
withTempStore((store) => {
|
|
27
|
+
store.setProfile("http://localhost:8080", {
|
|
28
|
+
token: "dsql_mcp_x.y",
|
|
29
|
+
username: "alice",
|
|
30
|
+
tokenId: 7,
|
|
31
|
+
});
|
|
32
|
+
const profile = store.getProfile("http://localhost:8080");
|
|
33
|
+
assert.equal(profile.token, "dsql_mcp_x.y");
|
|
34
|
+
assert.equal(profile.username, "alice");
|
|
35
|
+
assert.equal(store.defaultBaseUrl(), "http://localhost:8080");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("normalizes trailing slash so http://x and http://x/ share a profile", () => {
|
|
40
|
+
withTempStore((store) => {
|
|
41
|
+
store.setProfile("http://localhost:8080/", { token: "t", username: "a" });
|
|
42
|
+
const profile = store.getProfile("http://localhost:8080");
|
|
43
|
+
assert.ok(profile);
|
|
44
|
+
assert.equal(profile.token, "t");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("removeProfile clears the profile and reassigns default", () => {
|
|
49
|
+
withTempStore((store) => {
|
|
50
|
+
store.setProfile("http://a", { token: "ta", username: "alice" });
|
|
51
|
+
store.setProfile("http://b", { token: "tb", username: "bob" });
|
|
52
|
+
store.removeProfile("http://a");
|
|
53
|
+
assert.equal(store.getProfile("http://a"), null);
|
|
54
|
+
assert.equal(store.defaultBaseUrl(), "http://b");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("auth file is written with mode 0600", { skip: process.platform === "win32" }, () => {
|
|
59
|
+
withTempStore((store, dir) => {
|
|
60
|
+
store.setProfile("http://localhost:8080", { token: "t", username: "a" });
|
|
61
|
+
const stat = fs.statSync(path.join(dir, "auth.json"));
|
|
62
|
+
assert.equal(stat.mode & 0o777, 0o600);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects loose perms unless DEEPSQL_INSECURE_AUTH=1", { skip: process.platform === "win32" }, () => {
|
|
67
|
+
withTempStore((store, dir) => {
|
|
68
|
+
store.setProfile("http://localhost:8080", { token: "t", username: "a" });
|
|
69
|
+
fs.chmodSync(path.join(dir, "auth.json"), 0o644);
|
|
70
|
+
assert.throws(() => store.load(), /insecure permissions/);
|
|
71
|
+
process.env.DEEPSQL_INSECURE_AUTH = "1";
|
|
72
|
+
try {
|
|
73
|
+
assert.doesNotThrow(() => store.load());
|
|
74
|
+
} finally {
|
|
75
|
+
delete process.env.DEEPSQL_INSECURE_AUTH;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dispatcher + lightweight argv parser for the `deepsql` CLI.
|
|
5
|
+
*
|
|
6
|
+
* Avoids a heavy CLI framework on purpose — keeps the npm install footprint
|
|
7
|
+
* small for the self-hosted distribution and makes it trivial to embed in
|
|
8
|
+
* scripts. Supports:
|
|
9
|
+
* - boolean flags: --json, --device, --browser, --no-browser
|
|
10
|
+
* - value flags: --url <url>, --token <t>, --connection <id>, --limit 50
|
|
11
|
+
* - subcommands: deepsql connections list, deepsql config show
|
|
12
|
+
* - positional: deepsql ask "what tables exist?"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const COMMANDS = {
|
|
16
|
+
login: () => require("./commands/login"),
|
|
17
|
+
logout: () => require("./commands/logout"),
|
|
18
|
+
whoami: () => require("./commands/whoami"),
|
|
19
|
+
config: () => require("./commands/config"),
|
|
20
|
+
mcp: () => require("./commands/mcp"),
|
|
21
|
+
connections: () => require("./commands/connections"),
|
|
22
|
+
ask: () => require("./commands/ask"),
|
|
23
|
+
query: () => require("./commands/query"),
|
|
24
|
+
explain: () => require("./commands/explain"),
|
|
25
|
+
schema: () => require("./commands/schema"),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const HELP = `deepsql — DeepSQL CLI
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
deepsql <command> [options]
|
|
32
|
+
|
|
33
|
+
Commands:
|
|
34
|
+
login [--url <url>] [--device|--browser] [--no-browser] [--label <name>]
|
|
35
|
+
Authorize this CLI with a DeepSQL instance.
|
|
36
|
+
logout [--url <url>] Revoke and forget the saved token.
|
|
37
|
+
whoami Show the user behind the saved token.
|
|
38
|
+
config show List saved profiles.
|
|
39
|
+
config set-default <url> Set the default profile.
|
|
40
|
+
config path Print the auth file path.
|
|
41
|
+
mcp Run the stdio MCP server using the saved token.
|
|
42
|
+
connections list [--json] List database connections.
|
|
43
|
+
ask "<question>" --connection <id> [--chat <id>] [--json]
|
|
44
|
+
Ask DeepSQL a question.
|
|
45
|
+
query "<sql>" --connection <id> [--limit <n>] [--timeout-seconds <n>] [--file <path>] [--json]
|
|
46
|
+
Run a read-only SQL query.
|
|
47
|
+
explain "<sql>" --connection <id> [--file <path>] [--json]
|
|
48
|
+
Get an EXPLAIN plan.
|
|
49
|
+
schema [tables|objects] --connection <id>
|
|
50
|
+
Dump schema or database objects as JSON.
|
|
51
|
+
|
|
52
|
+
Global options:
|
|
53
|
+
--url <url> Override the DeepSQL base URL.
|
|
54
|
+
--token <tok> Override the auth token (also: DEEPSQL_AUTH_TOKEN).
|
|
55
|
+
-h, --help Show help.
|
|
56
|
+
-v, --version Show version.
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const result = { positional: [], flags: {} };
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const arg = argv[i];
|
|
63
|
+
if (arg === "--") {
|
|
64
|
+
result.positional.push(...argv.slice(i + 1));
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (arg.startsWith("--")) {
|
|
68
|
+
const eq = arg.indexOf("=");
|
|
69
|
+
const key = (eq === -1 ? arg.slice(2) : arg.slice(2, eq));
|
|
70
|
+
let value;
|
|
71
|
+
if (eq !== -1) {
|
|
72
|
+
value = arg.slice(eq + 1);
|
|
73
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
74
|
+
value = argv[++i];
|
|
75
|
+
} else {
|
|
76
|
+
value = true;
|
|
77
|
+
}
|
|
78
|
+
result.flags[normalizeKey(key)] = coerce(value);
|
|
79
|
+
} else if (arg.startsWith("-") && arg.length > 1) {
|
|
80
|
+
const key = arg.slice(1);
|
|
81
|
+
result.flags[normalizeKey(key)] = true;
|
|
82
|
+
} else {
|
|
83
|
+
result.positional.push(arg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeKey(key) {
|
|
90
|
+
// --no-browser → noBrowser, --timeout-seconds → timeoutSeconds
|
|
91
|
+
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function coerce(value) {
|
|
95
|
+
if (value === "true") return true;
|
|
96
|
+
if (value === "false") return false;
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildOpts(parsed) {
|
|
101
|
+
const f = parsed.flags;
|
|
102
|
+
return {
|
|
103
|
+
positional: parsed.positional,
|
|
104
|
+
url: f.url || null,
|
|
105
|
+
token: f.token || null,
|
|
106
|
+
json: !!f.json,
|
|
107
|
+
device: !!f.device,
|
|
108
|
+
browser: !!f.browser,
|
|
109
|
+
noBrowser: !!f.noBrowser,
|
|
110
|
+
label: f.label || null,
|
|
111
|
+
connection: f.connection || f.c || null,
|
|
112
|
+
chat: f.chat || null,
|
|
113
|
+
user: f.user || null,
|
|
114
|
+
project: f.project || null,
|
|
115
|
+
limit: f.limit,
|
|
116
|
+
timeoutSeconds: f.timeoutSeconds,
|
|
117
|
+
file: f.file || null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
122
|
+
const stderr = io.stderr || process.stderr;
|
|
123
|
+
const stdout = io.stdout || process.stdout;
|
|
124
|
+
|
|
125
|
+
if (rawArgv.length === 0 || rawArgv[0] === "--help" || rawArgv[0] === "-h") {
|
|
126
|
+
stdout.write(`${HELP}\n`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (rawArgv[0] === "--version" || rawArgv[0] === "-v") {
|
|
130
|
+
const pkg = require("../package.json");
|
|
131
|
+
stdout.write(`${pkg.version}\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const command = rawArgv[0];
|
|
136
|
+
const loader = COMMANDS[command];
|
|
137
|
+
if (!loader) {
|
|
138
|
+
stderr.write(`Unknown command: ${command}\n\n${HELP}\n`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parsed = parseArgs(rawArgv.slice(1));
|
|
143
|
+
const opts = buildOpts(parsed);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const mod = loader();
|
|
147
|
+
await mod.run(opts, { stdout, stderr });
|
|
148
|
+
return 0;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
stderr.write(`Error: ${err.message}\n`);
|
|
151
|
+
if (process.env.DEEPSQL_DEBUG === "1" && err.stack) {
|
|
152
|
+
stderr.write(`${err.stack}\n`);
|
|
153
|
+
}
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { main, parseArgs, buildOpts };
|
package/src/cli.test.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
const { parseArgs, buildOpts } = require("./cli");
|
|
7
|
+
|
|
8
|
+
test("parseArgs collects positional args", () => {
|
|
9
|
+
const { positional, flags } = parseArgs(["how", "many", "rows"]);
|
|
10
|
+
assert.deepEqual(positional, ["how", "many", "rows"]);
|
|
11
|
+
assert.deepEqual(flags, {});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("parseArgs handles --flag value and --flag=value", () => {
|
|
15
|
+
const a = parseArgs(["--url", "http://x", "--connection=c1"]);
|
|
16
|
+
assert.equal(a.flags.url, "http://x");
|
|
17
|
+
assert.equal(a.flags.connection, "c1");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parseArgs camelCases dashed flags", () => {
|
|
21
|
+
const { flags } = parseArgs(["--no-browser", "--timeout-seconds", "30"]);
|
|
22
|
+
assert.equal(flags.noBrowser, true);
|
|
23
|
+
assert.equal(flags.timeoutSeconds, "30");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("parseArgs treats trailing -- as positional separator", () => {
|
|
27
|
+
const { positional } = parseArgs(["query", "--", "SELECT 1"]);
|
|
28
|
+
assert.deepEqual(positional, ["query", "SELECT 1"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("buildOpts maps known flags", () => {
|
|
32
|
+
const parsed = parseArgs(["--connection", "c1", "--json", "--limit", "50", "SELECT 1"]);
|
|
33
|
+
const opts = buildOpts(parsed);
|
|
34
|
+
assert.equal(opts.connection, "c1");
|
|
35
|
+
assert.equal(opts.json, true);
|
|
36
|
+
assert.equal(opts.limit, "50");
|
|
37
|
+
assert.deepEqual(opts.positional, ["SELECT 1"]);
|
|
38
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const store = require("../auth/store");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the (baseUrl, token) tuple a command should use.
|
|
7
|
+
*
|
|
8
|
+
* Precedence — highest first:
|
|
9
|
+
* 1. --token CLI flag
|
|
10
|
+
* 2. DEEPSQL_AUTH_TOKEN env var (back-compat with the legacy MCP setup)
|
|
11
|
+
* 3. Saved profile for --url, or the default profile
|
|
12
|
+
*
|
|
13
|
+
* The base URL falls back from --url → DEEPSQL_API_BASE_URL → saved profile.
|
|
14
|
+
*/
|
|
15
|
+
function resolveSession(opts = {}) {
|
|
16
|
+
const explicitToken = opts.token || process.env.DEEPSQL_AUTH_TOKEN || null;
|
|
17
|
+
|
|
18
|
+
let baseUrl = opts.url ? store.normalizeBaseUrl(opts.url) : null;
|
|
19
|
+
if (!baseUrl && process.env.DEEPSQL_API_BASE_URL) {
|
|
20
|
+
baseUrl = store.normalizeBaseUrl(stripApiSuffix(process.env.DEEPSQL_API_BASE_URL));
|
|
21
|
+
}
|
|
22
|
+
if (!baseUrl) baseUrl = store.defaultBaseUrl();
|
|
23
|
+
|
|
24
|
+
let profile = null;
|
|
25
|
+
if (baseUrl) profile = store.getProfile(baseUrl);
|
|
26
|
+
|
|
27
|
+
const token = explicitToken || profile?.token || null;
|
|
28
|
+
if (!baseUrl) {
|
|
29
|
+
throw new Error("No DeepSQL URL configured. Run `deepsql login --url <url>`.");
|
|
30
|
+
}
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new Error(`No auth token for ${baseUrl}. Run \`deepsql login --url ${baseUrl}\`.`);
|
|
33
|
+
}
|
|
34
|
+
return { baseUrl, token, profile };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stripApiSuffix(url) {
|
|
38
|
+
return url.replace(/\/?api\/?$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { resolveSession };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const { request } = require("../api/client");
|
|
5
|
+
const { resolveSession } = require("./_session");
|
|
6
|
+
|
|
7
|
+
async function run(opts, { stdout = process.stdout } = {}) {
|
|
8
|
+
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.");
|
|
11
|
+
|
|
12
|
+
const session = resolveSession(opts);
|
|
13
|
+
const userId = opts.user || `cli-${os.userInfo().username}`;
|
|
14
|
+
const projectId = opts.project || "deepsql-cli";
|
|
15
|
+
|
|
16
|
+
const response = await request(session.baseUrl, "/chat", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
token: session.token,
|
|
19
|
+
timeoutMs: 240000,
|
|
20
|
+
json: {
|
|
21
|
+
connectionId: opts.connection,
|
|
22
|
+
message: question,
|
|
23
|
+
chatId: opts.chat || null,
|
|
24
|
+
userId,
|
|
25
|
+
projectId,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const answer = response?.answer || response?.message || response?.content || null;
|
|
35
|
+
if (answer) {
|
|
36
|
+
stdout.write(`${answer}\n`);
|
|
37
|
+
} else {
|
|
38
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { run };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const store = require("../auth/store");
|
|
4
|
+
|
|
5
|
+
async function run(args, { stdout = process.stdout } = {}) {
|
|
6
|
+
const sub = args.positional[0] || "show";
|
|
7
|
+
switch (sub) {
|
|
8
|
+
case "show":
|
|
9
|
+
return showConfig(stdout);
|
|
10
|
+
case "set-default":
|
|
11
|
+
return setDefault(args.positional[1], stdout);
|
|
12
|
+
case "path":
|
|
13
|
+
stdout.write(`${store.authFilePath()}\n`);
|
|
14
|
+
return;
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(`Unknown config subcommand: ${sub}. Try \`show\`, \`set-default <url>\`, or \`path\`.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function showConfig(stdout) {
|
|
21
|
+
const state = store.listProfiles();
|
|
22
|
+
const profiles = Object.keys(state.profiles || {});
|
|
23
|
+
if (profiles.length === 0) {
|
|
24
|
+
stdout.write("No saved profiles. Run `deepsql login --url <url>` to add one.\n");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
stdout.write(`Default: ${state.default || "(none)"}\n`);
|
|
28
|
+
stdout.write(`Profiles:\n`);
|
|
29
|
+
for (const url of profiles) {
|
|
30
|
+
const p = state.profiles[url];
|
|
31
|
+
const marker = url === state.default ? " *" : " ";
|
|
32
|
+
stdout.write(`${marker} ${url} user=${p.username || "?"} tokenId=${p.tokenId || "?"}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setDefault(url, stdout) {
|
|
37
|
+
if (!url) throw new Error("Pass the base URL: `deepsql config set-default <url>`.");
|
|
38
|
+
store.setDefault(store.normalizeBaseUrl(url));
|
|
39
|
+
stdout.write(`Default profile is now ${store.normalizeBaseUrl(url)}.\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { run };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { request } = require("../api/client");
|
|
4
|
+
const { resolveSession } = require("./_session");
|
|
5
|
+
|
|
6
|
+
async function run(opts, { stdout = process.stdout } = {}) {
|
|
7
|
+
const sub = opts.positional[0] || "list";
|
|
8
|
+
if (sub !== "list") {
|
|
9
|
+
throw new Error(`Unknown connections subcommand: ${sub}. Try \`list\`.`);
|
|
10
|
+
}
|
|
11
|
+
const session = resolveSession(opts);
|
|
12
|
+
const data = await request(session.baseUrl, "/connections", { token: session.token });
|
|
13
|
+
if (opts.json) {
|
|
14
|
+
stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
18
|
+
stdout.write("No connections.\n");
|
|
19
|
+
return;
|
|
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`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { run };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const { request } = require("../api/client");
|
|
5
|
+
const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
|
|
6
|
+
const { resolveSession } = require("./_session");
|
|
7
|
+
|
|
8
|
+
async function run(opts, { stdout = process.stdout } = {}) {
|
|
9
|
+
if (!opts.connection) throw new Error("--connection <id> is required.");
|
|
10
|
+
const sql = readSqlInput(opts);
|
|
11
|
+
// EXPLAIN ANALYZE is mutating; require plain EXPLAIN.
|
|
12
|
+
const validation = validateReadOnlySql(sql, { allowExplain: false });
|
|
13
|
+
if (!validation.ok) throw new Error(validation.reason);
|
|
14
|
+
|
|
15
|
+
const session = resolveSession(opts);
|
|
16
|
+
const response = await request(session.baseUrl, "/mcp/explain-readonly", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
token: session.token,
|
|
19
|
+
json: { connectionId: opts.connection, query: validation.normalizedQuery },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (response?.plan) {
|
|
27
|
+
stdout.write(typeof response.plan === "string" ? `${response.plan}\n` : `${JSON.stringify(response.plan, null, 2)}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readSqlInput(opts) {
|
|
34
|
+
if (opts.file) return fs.readFileSync(opts.file, "utf8");
|
|
35
|
+
if (opts.positional.length > 0) return opts.positional.join(" ");
|
|
36
|
+
if (!process.stdin.isTTY) return fs.readFileSync(0, "utf8");
|
|
37
|
+
throw new Error("Pass SQL as an argument, via --file <path>, or pipe it to stdin.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { run };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const { runBrowserFlow } = require("../auth/browser-flow");
|
|
5
|
+
const { runDeviceFlow } = require("../auth/device-flow");
|
|
6
|
+
const store = require("../auth/store");
|
|
7
|
+
const { ApiError } = require("../api/client");
|
|
8
|
+
|
|
9
|
+
function defaultClientLabel() {
|
|
10
|
+
return `deepsql-cli@${process.versions.node}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
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;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function run(opts, { stderr = process.stderr, stdout = process.stdout } = {}) {
|
|
23
|
+
const baseUrl = opts.url ? store.normalizeBaseUrl(opts.url) : store.defaultBaseUrl();
|
|
24
|
+
if (!baseUrl) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"Pass --url <https://your-deepsql> on first login (no default profile is saved yet).",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const hostname = os.hostname();
|
|
30
|
+
const label = opts.label || defaultClientLabel();
|
|
31
|
+
const useDevice = shouldUseDevice(opts);
|
|
32
|
+
const log = (msg) => stderr.write(`[deepsql] ${msg}\n`);
|
|
33
|
+
|
|
34
|
+
let issued;
|
|
35
|
+
try {
|
|
36
|
+
if (useDevice) {
|
|
37
|
+
log(`Starting device-code login against ${baseUrl}…`);
|
|
38
|
+
issued = await runDeviceFlow({ baseUrl, hostname, clientLabel: label, log });
|
|
39
|
+
} else {
|
|
40
|
+
log(`Starting browser login against ${baseUrl}…`);
|
|
41
|
+
issued = await runBrowserFlow({
|
|
42
|
+
baseUrl,
|
|
43
|
+
hostname,
|
|
44
|
+
clientLabel: label,
|
|
45
|
+
log,
|
|
46
|
+
openBrowser: !opts.noBrowser,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof ApiError && err.status === 0) {
|
|
51
|
+
throw new Error(`Could not reach ${baseUrl}. Is the DeepSQL backend running?`);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
store.setProfile(baseUrl, {
|
|
57
|
+
token: issued.token,
|
|
58
|
+
tokenId: issued.token_id,
|
|
59
|
+
username: issued.username,
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
expiresAt: issued.expires_at || null,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
stdout.write(`Authorized as ${issued.username} at ${baseUrl}\n`);
|
|
65
|
+
stdout.write(`Token saved to ${store.authFilePath()}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { run };
|