@deepsql/mcp 0.18.2 → 0.20.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/CLAUDE.md +24 -4
- package/README.md +106 -7
- package/agent-profile/SOUL.md +28 -0
- package/agent-profile/distribution.yaml +41 -0
- package/agent-profile/skills/bi-query/SKILL.md +40 -0
- package/agent-profile/skills/index-advisor/SKILL.md +34 -0
- package/agent-profile/skills/schema-exploration/SKILL.md +32 -0
- package/agent-profile/skills/slow-query-optimize/SKILL.md +31 -0
- package/agent-profile/skills/workload-analysis/SKILL.md +36 -0
- package/agent-profile/skins/deepsql.yaml +46 -0
- package/agent-profile/tui/HERMES_VERSION +1 -0
- package/agent-profile/tui/entry.js +92903 -0
- package/deepsql-phase1-lib.js +561 -4
- package/package.json +3 -2
- package/skills/SKILL_BODY.md +28 -7
- package/src/agent/bootstrap.js +205 -0
- package/src/cli.js +36 -4
- package/src/cli.test.js +50 -0
- package/src/commands/agent.js +44 -0
- package/src/commands/connections.js +7 -4
- package/src/commands/connections.test.js +93 -0
- package/src/commands/slow-queries.js +2 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Lazy bootstrap for the DeepSQL Agent (Hermes-based TUI): detect/install the
|
|
4
|
+
// Hermes runtime and provision the `deepsql` profile (model → the backend LLM
|
|
5
|
+
// proxy with the user's token; DeepSQL MCP tools; DBA persona/skills; subtle
|
|
6
|
+
// DeepSQL skin; read-only sandbox). Provisioning is idempotent; the per-launch
|
|
7
|
+
// config rewrite keeps the token/connection fresh.
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
const { spawnSync } = require("node:child_process");
|
|
12
|
+
const { userHome } = require("../user-home");
|
|
13
|
+
|
|
14
|
+
const HOME = userHome();
|
|
15
|
+
const HERMES_HOME = path.join(HOME, ".hermes");
|
|
16
|
+
const AGENT_DIR = path.join(HERMES_HOME, "hermes-agent");
|
|
17
|
+
const PROFILE = "deepsql";
|
|
18
|
+
const PROFILE_HOME = path.join(HERMES_HOME, "profiles", PROFILE);
|
|
19
|
+
|
|
20
|
+
function hermesBin() {
|
|
21
|
+
const venv = path.join(AGENT_DIR, ".venv", "bin", "hermes");
|
|
22
|
+
return fs.existsSync(venv) ? venv : "hermes";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hermesInstalled() {
|
|
26
|
+
if (fs.existsSync(path.join(AGENT_DIR, ".venv", "bin", "hermes"))) return true;
|
|
27
|
+
const r = spawnSync("hermes", ["--version"], { stdio: "ignore" });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mcpServerPath() {
|
|
32
|
+
return path.resolve(__dirname, "..", "..", "deepsql-phase1-server.js");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Profile distribution (SOUL.md + skills/ + skins/): the bundled copy shipped in
|
|
36
|
+
// the package, else the repo `hermes/` dir during local development.
|
|
37
|
+
function distSource() {
|
|
38
|
+
const bundled = path.resolve(__dirname, "..", "..", "agent-profile");
|
|
39
|
+
if (fs.existsSync(path.join(bundled, "distribution.yaml"))) return bundled;
|
|
40
|
+
const repoHermes = path.resolve(__dirname, "..", "..", "..", "hermes");
|
|
41
|
+
if (fs.existsSync(path.join(repoHermes, "distribution.yaml"))) return repoHermes;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureHermes(io) {
|
|
46
|
+
const err = (io && io.stderr) || process.stderr;
|
|
47
|
+
if (hermesInstalled()) return true;
|
|
48
|
+
err.write("DeepSQL Agent needs its runtime (first run) — installing, this may take a minute…\n");
|
|
49
|
+
const r = spawnSync(
|
|
50
|
+
"bash",
|
|
51
|
+
["-lc", "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-setup"],
|
|
52
|
+
{ stdio: "inherit" }
|
|
53
|
+
);
|
|
54
|
+
return r.status === 0 && hermesInstalled();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function q(s) {
|
|
58
|
+
return `"${String(s).replace(/"/g, '\\"')}"`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeRuntimeConfig(session) {
|
|
62
|
+
fs.mkdirSync(PROFILE_HOME, { recursive: true });
|
|
63
|
+
const base = session.baseUrl.replace(/\/?$/, "/"); // ensure single trailing /
|
|
64
|
+
const proxy = base + "api/llm/v1";
|
|
65
|
+
const apiBase = base + "api/";
|
|
66
|
+
const token = session.token;
|
|
67
|
+
const mcp = mcpServerPath();
|
|
68
|
+
const conn = session.defaultConnection || "";
|
|
69
|
+
|
|
70
|
+
const config =
|
|
71
|
+
`model:
|
|
72
|
+
default: gpt-5.4
|
|
73
|
+
provider: custom
|
|
74
|
+
base_url: ${q(proxy)}
|
|
75
|
+
api_key: ${q(token)}
|
|
76
|
+
api_mode: chat_completions
|
|
77
|
+
context_length: 272000
|
|
78
|
+
mcp_servers:
|
|
79
|
+
deepsql:
|
|
80
|
+
command: node
|
|
81
|
+
args:
|
|
82
|
+
- ${q(mcp)}
|
|
83
|
+
env:
|
|
84
|
+
DEEPSQL_API_BASE_URL: ${q(apiBase)}
|
|
85
|
+
DEEPSQL_MCP_USER_ID: deepsql-cli
|
|
86
|
+
DEEPSQL_AUTH_TOKEN: ${q(token)}
|
|
87
|
+
approvals:
|
|
88
|
+
mode: smart
|
|
89
|
+
display:
|
|
90
|
+
skin: deepsql
|
|
91
|
+
interface: tui
|
|
92
|
+
`;
|
|
93
|
+
fs.writeFileSync(path.join(PROFILE_HOME, "config.yaml"), config);
|
|
94
|
+
|
|
95
|
+
const env =
|
|
96
|
+
`DEEPSQL_API_BASE_URL=${apiBase}
|
|
97
|
+
DEEPSQL_AUTH_TOKEN=${token}
|
|
98
|
+
DEEPSQL_MCP_SERVER=${mcp}
|
|
99
|
+
DEEPSQL_DEFAULT_CONNECTION_ID=${conn}
|
|
100
|
+
HERMES_REVISION=deepsql-cli
|
|
101
|
+
`;
|
|
102
|
+
fs.writeFileSync(path.join(PROFILE_HOME, ".env"), env, { mode: 0o600 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function installProfileAssets(io) {
|
|
106
|
+
const err = (io && io.stderr) || process.stderr;
|
|
107
|
+
const src = distSource();
|
|
108
|
+
if (!src) {
|
|
109
|
+
err.write("DeepSQL agent profile assets not found in this install.\n");
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const bin = hermesBin();
|
|
113
|
+
const baseEnv = { ...process.env, UV_NO_CONFIG: "1" };
|
|
114
|
+
spawnSync(bin, ["profile", "install", src, "--name", PROFILE, "--force", "--yes"], { stdio: "ignore", env: baseEnv });
|
|
115
|
+
// Skin loads from <profile>/skins/.
|
|
116
|
+
const skin = path.join(src, "skins", "deepsql.yaml");
|
|
117
|
+
if (fs.existsSync(skin)) {
|
|
118
|
+
const dst = path.join(PROFILE_HOME, "skins");
|
|
119
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
120
|
+
fs.copyFileSync(skin, path.join(dst, "deepsql.yaml"));
|
|
121
|
+
}
|
|
122
|
+
// Read-only sandbox: only deepsql + skills/memory remain.
|
|
123
|
+
spawnSync(
|
|
124
|
+
bin,
|
|
125
|
+
["tools", "disable", "terminal", "file", "code_execution", "browser", "computer_use",
|
|
126
|
+
"image_gen", "tts", "vision", "web", "delegation", "cronjob"],
|
|
127
|
+
{ stdio: "ignore", env: { ...baseEnv, HERMES_HOME: PROFILE_HOME } }
|
|
128
|
+
);
|
|
129
|
+
return fs.existsSync(PROFILE_HOME);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function ensureProfile(session, io) {
|
|
133
|
+
if (!fs.existsSync(path.join(PROFILE_HOME, "config.yaml"))) {
|
|
134
|
+
if (!installProfileAssets(io)) return false;
|
|
135
|
+
}
|
|
136
|
+
writeRuntimeConfig(session); // always refresh the token/connection for this launch
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Installed Hermes version (parsed from the runtime's __init__.py). Null if the
|
|
141
|
+
// runtime layout isn't what we expect (e.g. PATH-only install) — overlay is then
|
|
142
|
+
// skipped and the user gets the stock TUI with our skin.
|
|
143
|
+
function installedHermesVersion() {
|
|
144
|
+
try {
|
|
145
|
+
const init = path.join(AGENT_DIR, "hermes_cli", "__init__.py");
|
|
146
|
+
const m = fs.readFileSync(init, "utf8").match(/__version__\s*=\s*["']([^"']+)["']/);
|
|
147
|
+
return m ? m[1] : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Overlay the prebuilt, DeepSQL-branded TUI bundle (custom DEEPSQL wordmark,
|
|
154
|
+
// "What I can do" welcome panel, DBA placeholders) onto the installed Hermes
|
|
155
|
+
// runtime's `ui-tui/dist/entry.js`. This delivers the full branded experience
|
|
156
|
+
// with NO build step on the user's machine.
|
|
157
|
+
//
|
|
158
|
+
// Guarded by the Hermes version our bundle was built against: a prebuilt
|
|
159
|
+
// entry.js speaks the TUI↔CLI protocol of one Hermes release, so we overlay
|
|
160
|
+
// only on an exact version match. On any mismatch (or a missing bundle) we
|
|
161
|
+
// leave the stock TUI in place — it still renders our skin (DeepSQL name +
|
|
162
|
+
// maroon palette via skins/deepsql.yaml), just without the custom wordmark.
|
|
163
|
+
//
|
|
164
|
+
// We refresh the overlay on every launch and bump its mtime past the TUI build
|
|
165
|
+
// inputs so Hermes' freshness check (`_tui_need_rebuild`, an mtime compare of
|
|
166
|
+
// dist/entry.js vs src/) never rebuilds over it after a `hermes update`.
|
|
167
|
+
function ensureBrandedTui(io) {
|
|
168
|
+
const err = (io && io.stderr) || process.stderr;
|
|
169
|
+
try {
|
|
170
|
+
const tuiDir = path.resolve(__dirname, "..", "..", "agent-profile", "tui");
|
|
171
|
+
const bundledEntry = path.join(tuiDir, "entry.js");
|
|
172
|
+
if (!fs.existsSync(bundledEntry)) return false; // skin-only branding
|
|
173
|
+
const builtFor = (() => {
|
|
174
|
+
try { return fs.readFileSync(path.join(tuiDir, "HERMES_VERSION"), "utf8").trim(); }
|
|
175
|
+
catch { return null; }
|
|
176
|
+
})();
|
|
177
|
+
const installed = installedHermesVersion();
|
|
178
|
+
if (!builtFor || !installed || builtFor !== installed) return false; // degrade to stock TUI + skin
|
|
179
|
+
|
|
180
|
+
const distDir = path.join(AGENT_DIR, "ui-tui", "dist");
|
|
181
|
+
const target = path.join(distDir, "entry.js");
|
|
182
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
183
|
+
const same =
|
|
184
|
+
fs.existsSync(target) && fs.statSync(target).size === fs.statSync(bundledEntry).size;
|
|
185
|
+
if (!same) fs.copyFileSync(bundledEntry, target);
|
|
186
|
+
// Bump mtime past every TUI build input so the freshness check never
|
|
187
|
+
// rebuilds the stock TUI over our branded bundle.
|
|
188
|
+
const future = new Date(Date.now() + 60_000);
|
|
189
|
+
fs.utimesSync(target, future, future);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
err.write(`DeepSQL Agent: branded TUI overlay skipped (${e.message}); using stock TUI.\n`);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
ensureHermes,
|
|
199
|
+
ensureProfile,
|
|
200
|
+
ensureBrandedTui,
|
|
201
|
+
hermesBin,
|
|
202
|
+
hermesInstalled,
|
|
203
|
+
PROFILE,
|
|
204
|
+
PROFILE_HOME,
|
|
205
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const COMMANDS = {
|
|
17
|
+
agent: () => require("./commands/agent"),
|
|
17
18
|
login: () => require("./commands/login"),
|
|
18
19
|
logout: () => require("./commands/logout"),
|
|
19
20
|
whoami: () => require("./commands/whoami"),
|
|
@@ -46,6 +47,7 @@ const COMMANDS = {
|
|
|
46
47
|
// suffix in the listing and reveal their full usage via `<command> --help`.
|
|
47
48
|
|
|
48
49
|
const COMMAND_LIST = [
|
|
50
|
+
["agent", false, "Launch the DeepSQL Agent — interactive chat TUI (also: bare `deepsql`)"],
|
|
49
51
|
["login", false, "Authorize this CLI with a DeepSQL instance"],
|
|
50
52
|
["logout", false, "Revoke and forget the saved token"],
|
|
51
53
|
["whoami", false, "Show the user behind the saved token"],
|
|
@@ -87,6 +89,16 @@ const GLOBAL_OPTIONS = [
|
|
|
87
89
|
// constant; the dispatcher renders them on `<command> --help`.
|
|
88
90
|
|
|
89
91
|
const COMMAND_HELP = {
|
|
92
|
+
agent: {
|
|
93
|
+
description: "Launch the DeepSQL Agent — an interactive chat TUI for DBA work, BI questions, and database guardrails. Running `deepsql` with no command in a terminal launches it too.",
|
|
94
|
+
usage: "deepsql agent [options] (or just: deepsql)",
|
|
95
|
+
options: [
|
|
96
|
+
["--url <url>", "DeepSQL instance to use (default: saved login)"],
|
|
97
|
+
["--connection <name>", "Default connection for the session"],
|
|
98
|
+
],
|
|
99
|
+
notes: "First run installs the agent runtime. Uses your saved `deepsql login` — no LLM key needed (the backend proxies the model).",
|
|
100
|
+
},
|
|
101
|
+
|
|
90
102
|
login: {
|
|
91
103
|
description: "Authorize this CLI with a DeepSQL instance.",
|
|
92
104
|
usage: "deepsql login [options]",
|
|
@@ -650,7 +662,7 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
650
662
|
const stdout = io.stdout || process.stdout;
|
|
651
663
|
const useColor = colorEnabled(stdout, rawArgv);
|
|
652
664
|
|
|
653
|
-
if (
|
|
665
|
+
if (isHelpFlag(rawArgv[0])) {
|
|
654
666
|
stdout.write(`${renderRootHelp(useColor)}\n`);
|
|
655
667
|
return 0;
|
|
656
668
|
}
|
|
@@ -659,8 +671,14 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
659
671
|
stdout.write(`${pkg.version}\n`);
|
|
660
672
|
return 0;
|
|
661
673
|
}
|
|
674
|
+
// No subcommand: launch the DeepSQL Agent TUI in an interactive terminal;
|
|
675
|
+
// fall back to the root help when stdout is piped/redirected (scripts, CI).
|
|
676
|
+
if (rawArgv.length === 0 && !stdout.isTTY) {
|
|
677
|
+
stdout.write(`${renderRootHelp(useColor)}\n`);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
662
680
|
|
|
663
|
-
const command = rawArgv[0];
|
|
681
|
+
const command = rawArgv.length === 0 ? "agent" : rawArgv[0];
|
|
664
682
|
const loader = COMMANDS[command];
|
|
665
683
|
if (!loader) {
|
|
666
684
|
stderr.write(`Unknown command: ${command}\n\n${renderRootHelp(colorEnabled(stderr, rawArgv))}\n`);
|
|
@@ -668,7 +686,7 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
668
686
|
}
|
|
669
687
|
|
|
670
688
|
// Per-command help: `deepsql <command> --help` / `-h` (anywhere in args).
|
|
671
|
-
const restArgs = rawArgv.slice(1);
|
|
689
|
+
const restArgs = rawArgv.length === 0 ? [] : rawArgv.slice(1);
|
|
672
690
|
if (restArgs.some(isHelpFlag)) {
|
|
673
691
|
stdout.write(`${renderCommandHelp(command, useColor)}\n`);
|
|
674
692
|
return 0;
|
|
@@ -690,9 +708,17 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
690
708
|
version: pkg.version,
|
|
691
709
|
});
|
|
692
710
|
|
|
711
|
+
const previousExitCode = process.exitCode;
|
|
712
|
+
process.exitCode = undefined;
|
|
693
713
|
try {
|
|
694
714
|
const mod = loader();
|
|
695
|
-
await mod.run(opts, { stdout, stderr });
|
|
715
|
+
const commandCode = await mod.run(opts, { stdout, stderr });
|
|
716
|
+
if (typeof commandCode === "number") {
|
|
717
|
+
return commandCode;
|
|
718
|
+
}
|
|
719
|
+
if (typeof process.exitCode === "number" && process.exitCode !== 0) {
|
|
720
|
+
return process.exitCode;
|
|
721
|
+
}
|
|
696
722
|
return 0;
|
|
697
723
|
} catch (err) {
|
|
698
724
|
stderr.write(`Error: ${err.message}\n`);
|
|
@@ -700,6 +726,12 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
700
726
|
stderr.write(`${err.stack}\n`);
|
|
701
727
|
}
|
|
702
728
|
return 1;
|
|
729
|
+
} finally {
|
|
730
|
+
if (previousExitCode == null) {
|
|
731
|
+
process.exitCode = undefined;
|
|
732
|
+
} else {
|
|
733
|
+
process.exitCode = previousExitCode;
|
|
734
|
+
}
|
|
703
735
|
}
|
|
704
736
|
}
|
|
705
737
|
|
package/src/cli.test.js
CHANGED
|
@@ -73,6 +73,7 @@ test("every command in the catalog has a COMMAND_HELP entry", () => {
|
|
|
73
73
|
const { main: _main } = require("./cli");
|
|
74
74
|
void _main;
|
|
75
75
|
const expected = [
|
|
76
|
+
"agent",
|
|
76
77
|
"login","logout","whoami","config","mcp","connections","query","analyze","schema",
|
|
77
78
|
"digest","brain-context","business-rules","relationships","anti-patterns","indexes",
|
|
78
79
|
"users","access","permissions","slow-queries","setup",
|
|
@@ -108,6 +109,13 @@ test("main rejects unknown commands and shows the root help", async () => {
|
|
|
108
109
|
assert.match(io.err(), /Usage: deepsql/);
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
test("main preserves a non-zero command return code", async () => {
|
|
113
|
+
const io = captureStreams();
|
|
114
|
+
const code = await main(["connections", "current", "--url", "http://x", "--token", "t"], io);
|
|
115
|
+
assert.equal(code, 1);
|
|
116
|
+
assert.match(io.err(), /No active connection set/);
|
|
117
|
+
});
|
|
118
|
+
|
|
111
119
|
test("--no-color suppresses ANSI escapes in help output", async () => {
|
|
112
120
|
const io = captureStreams();
|
|
113
121
|
// Force a TTY so the only thing turning color off is --no-color itself.
|
|
@@ -116,3 +124,45 @@ test("--no-color suppresses ANSI escapes in help output", async () => {
|
|
|
116
124
|
// No ESC bytes anywhere.
|
|
117
125
|
assert.equal(/\x1b\[/.test(io.out()), false, "help output should be plain when --no-color is set");
|
|
118
126
|
});
|
|
127
|
+
|
|
128
|
+
// ─── help/dispatch drift guard ─────────────────────────────────────────────
|
|
129
|
+
//
|
|
130
|
+
// Every command-module SUBCOMMANDS map must be reflected in COMMAND_HELP so
|
|
131
|
+
// `deepsql <command> -h` actually documents what `deepsql <command> <sub>`
|
|
132
|
+
// will dispatch. A previous regression shipped four new slow-query
|
|
133
|
+
// subcommands that worked but weren't listed in -h — users assumed the
|
|
134
|
+
// install was stale. This test catches that class of bug at PR time.
|
|
135
|
+
//
|
|
136
|
+
// To extend: add a `{ command, modulePath }` entry. The module must export
|
|
137
|
+
// a `SUBCOMMANDS` object whose keys are the dispatchable subcommand names.
|
|
138
|
+
const HELP_DRIFT_TARGETS = [
|
|
139
|
+
{ command: "slow-queries", modulePath: "./commands/slow-queries" },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
for (const { command, modulePath } of HELP_DRIFT_TARGETS) {
|
|
143
|
+
test(`COMMAND_HELP["${command}"] documents every dispatchable subcommand`, () => {
|
|
144
|
+
const mod = require(modulePath);
|
|
145
|
+
assert.ok(mod.SUBCOMMANDS, `${modulePath} must export SUBCOMMANDS for the drift guard`);
|
|
146
|
+
const help = COMMAND_HELP[command];
|
|
147
|
+
assert.ok(help, `COMMAND_HELP["${command}"] is missing`);
|
|
148
|
+
assert.ok(Array.isArray(help.subcommands), `COMMAND_HELP["${command}"].subcommands must be an array`);
|
|
149
|
+
|
|
150
|
+
const dispatchable = Object.keys(mod.SUBCOMMANDS).sort();
|
|
151
|
+
// Each help row is [usage-string, description]; the first whitespace-
|
|
152
|
+
// separated token of usage-string is the subcommand name.
|
|
153
|
+
const documented = help.subcommands
|
|
154
|
+
.map((row) => String(row[0]).trim().split(/\s+/)[0])
|
|
155
|
+
.sort();
|
|
156
|
+
const missing = dispatchable.filter((s) => !documented.includes(s));
|
|
157
|
+
const stale = documented.filter((s) => !dispatchable.includes(s));
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
{ missing, stale },
|
|
161
|
+
{ missing: [], stale: [] },
|
|
162
|
+
`\`deepsql ${command} -h\` is out of sync with src/commands/${command}.js:\n`
|
|
163
|
+
+ ` missing from help (callable but undocumented): ${missing.join(", ") || "none"}\n`
|
|
164
|
+
+ ` stale in help (documented but not callable): ${stale.join(", ") || "none"}\n`
|
|
165
|
+
+ `Fix: update COMMAND_HELP["${command}"].subcommands in src/cli.js.`,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `deepsql agent` (and bare `deepsql` in a terminal): launch the DeepSQL Agent —
|
|
4
|
+
// a branded Hermes TUI for DBA / BI / database-guard chat. Resolves the saved
|
|
5
|
+
// login, lazily installs the runtime, provisions the `deepsql` profile pointed
|
|
6
|
+
// at the backend LLM proxy (no LLM key needed), then hands the terminal to the TUI.
|
|
7
|
+
|
|
8
|
+
const { spawn } = require("node:child_process");
|
|
9
|
+
const { resolveSession } = require("./_session");
|
|
10
|
+
const { ensureHermes, ensureProfile, ensureBrandedTui, hermesBin, PROFILE } = require("../agent/bootstrap");
|
|
11
|
+
|
|
12
|
+
async function run(opts, io = {}) {
|
|
13
|
+
const stderr = io.stderr || process.stderr;
|
|
14
|
+
|
|
15
|
+
let session;
|
|
16
|
+
try {
|
|
17
|
+
session = resolveSession(opts);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
stderr.write(`${e.message}\n`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!ensureHermes(io)) {
|
|
24
|
+
stderr.write("Could not set up the DeepSQL Agent runtime. See https://github.com/NousResearch/hermes-agent for manual install.\n");
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
if (!ensureProfile(session, io)) {
|
|
28
|
+
stderr.write("Could not provision the DeepSQL Agent profile.\n");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
ensureBrandedTui(io); // best-effort: full branded TUI when versions match, else stock TUI + skin
|
|
32
|
+
|
|
33
|
+
// Hand the terminal to the branded TUI (profile config sets interface=tui).
|
|
34
|
+
const child = spawn(hermesBin(), ["-p", PROFILE, "--tui"], { stdio: "inherit", env: process.env });
|
|
35
|
+
return await new Promise((resolve) => {
|
|
36
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
37
|
+
child.on("error", (err) => {
|
|
38
|
+
stderr.write(`Failed to launch the DeepSQL Agent: ${err.message}\n`);
|
|
39
|
+
resolve(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { run };
|
|
@@ -333,10 +333,11 @@ async function runTest(opts, { stdout = process.stdout, stderr = process.stderr
|
|
|
333
333
|
throw new Error("Usage: deepsql connections test <name> | --from-file <path>");
|
|
334
334
|
}
|
|
335
335
|
const connectionId = await resolveConnectionId(session, target);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
336
|
+
// Send only the id for saved connections. The list/show APIs intentionally
|
|
337
|
+
// omit secrets, so posting that masked summary back to /connections/test
|
|
338
|
+
// breaks SSH/SSL/password-backed connections. The backend hydrates the
|
|
339
|
+
// saved decrypted config when an id is present.
|
|
340
|
+
cfg = { id: connectionId };
|
|
340
341
|
}
|
|
341
342
|
|
|
342
343
|
const result = await request(session.baseUrl, "/connections/test", {
|
|
@@ -352,7 +353,9 @@ async function runTest(opts, { stdout = process.stdout, stderr = process.stderr
|
|
|
352
353
|
printPrivilegeReport(stdout, result);
|
|
353
354
|
if (!result?.connectionSuccessful) {
|
|
354
355
|
process.exitCode = 1;
|
|
356
|
+
return 1;
|
|
355
357
|
}
|
|
358
|
+
return 0;
|
|
356
359
|
}
|
|
357
360
|
|
|
358
361
|
// ─── show ──────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
function opts(argv) {
|
|
9
|
+
return buildOpts(parseArgs(argv));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function captureStdout() {
|
|
13
|
+
let out = "";
|
|
14
|
+
let err = "";
|
|
15
|
+
return {
|
|
16
|
+
stream: { write: (s) => { out += s; } },
|
|
17
|
+
errStream: { write: (s) => { err += s; } },
|
|
18
|
+
out: () => out,
|
|
19
|
+
err: () => err,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadWithStubs({ onRequest }) {
|
|
24
|
+
for (const k of [
|
|
25
|
+
require.resolve("../api/client"),
|
|
26
|
+
require.resolve("./_session"),
|
|
27
|
+
require.resolve("./_connections"),
|
|
28
|
+
require.resolve("./connections"),
|
|
29
|
+
]) {
|
|
30
|
+
delete require.cache[k];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const apiKey = require.resolve("../api/client");
|
|
34
|
+
require.cache[apiKey] = {
|
|
35
|
+
id: apiKey, filename: apiKey, loaded: true,
|
|
36
|
+
exports: {
|
|
37
|
+
ApiError: class ApiError extends Error {},
|
|
38
|
+
async request(baseUrl, path, body) {
|
|
39
|
+
return onRequest(baseUrl, path, body);
|
|
40
|
+
},
|
|
41
|
+
setClientContext() {},
|
|
42
|
+
getClientContext() { return null; },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const sessKey = require.resolve("./_session");
|
|
47
|
+
require.cache[sessKey] = {
|
|
48
|
+
id: sessKey, filename: sessKey, loaded: true,
|
|
49
|
+
exports: {
|
|
50
|
+
resolveSession: () => ({
|
|
51
|
+
baseUrl: "http://test",
|
|
52
|
+
token: "t",
|
|
53
|
+
defaultConnection: null,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return require("./connections");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("connections test <saved-name> posts only the connection id", async () => {
|
|
62
|
+
const seen = [];
|
|
63
|
+
const connections = loadWithStubs({
|
|
64
|
+
onRequest: (_baseUrl, path, body) => {
|
|
65
|
+
seen.push({ path, body });
|
|
66
|
+
if (path === "/connections") {
|
|
67
|
+
return [{ id: "cid-1", connectionName: "prod", sshPrivateKey: "(masked)" }];
|
|
68
|
+
}
|
|
69
|
+
if (path === "/connections/test") {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
connectionSuccessful: false,
|
|
73
|
+
message: "Connection failed",
|
|
74
|
+
privileges: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`unexpected path ${path}`);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const stdout = captureStdout();
|
|
81
|
+
const previousExitCode = process.exitCode;
|
|
82
|
+
process.exitCode = undefined;
|
|
83
|
+
try {
|
|
84
|
+
const code = await connections.run(opts(["test", "prod"]), { stdout: stdout.stream });
|
|
85
|
+
|
|
86
|
+
assert.equal(code, 1);
|
|
87
|
+
assert.equal(process.exitCode, 1);
|
|
88
|
+
assert.deepEqual(seen.at(-1).body.json, { id: "cid-1" });
|
|
89
|
+
assert.match(stdout.out(), /Connection failed/);
|
|
90
|
+
} finally {
|
|
91
|
+
process.exitCode = previousExitCode;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -343,7 +343,7 @@ async function cmdRegressions(opts, { stdout = process.stdout } = {}) {
|
|
|
343
343
|
{ key: "calls", label: "CALLS" },
|
|
344
344
|
{ key: "sql", label: "QUERY" },
|
|
345
345
|
], items.map((r) => ({
|
|
346
|
-
fingerprint: trim(r.fingerprint || "",
|
|
346
|
+
fingerprint: trim(r.fingerprint || "", 16),
|
|
347
347
|
factor: r.regressionFactor != null ? `${r.regressionFactor.toFixed(2)}x` : "?",
|
|
348
348
|
meanMs: r.meanExecMs != null ? Math.round(r.meanExecMs).toString() : "?",
|
|
349
349
|
calls: String(r.callsDelta ?? "?"),
|
|
@@ -526,7 +526,7 @@ function printTrendRows(stdout, items) {
|
|
|
526
526
|
{ key: "factor", label: "VS PREV" },
|
|
527
527
|
{ key: "sql", label: "QUERY" },
|
|
528
528
|
], items.map((q) => ({
|
|
529
|
-
fingerprint: trim(q.fingerprint || "",
|
|
529
|
+
fingerprint: trim(q.fingerprint || "", 16),
|
|
530
530
|
meanMs: q.meanExecMs != null ? Math.round(q.meanExecMs).toString() : "?",
|
|
531
531
|
calls: String(q.callsDelta ?? "?"),
|
|
532
532
|
factor: q.regressionFactor != null ? `${q.regressionFactor.toFixed(2)}x` : "—",
|