@deepsql/mcp 0.19.0 → 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.
@@ -350,9 +350,11 @@ const TOOL_DEFINITIONS = [
350
350
  {
351
351
  name: "optimize_slow_query",
352
352
  description:
353
- "Get AI-generated optimization recommendations for a specific slow query — including "
354
- + "index suggestions, query rewrites, and expected performance impact. DeepSQL inspects "
355
- + "the query against the connection's schema, existing indexes, and workload patterns. "
353
+ "Get an AI query REWRITE and plan diagnosis for one specific slow query. "
354
+ + "Single-query scoped: returns a rewritten SQL (validated against the live DB), "
355
+ + "the plan bottleneck, and an estimated improvement. Does NOT recommend indexes "
356
+ + "index and pre-aggregation recommendations require the whole workload and come "
357
+ + "from the holistic Workload Analysis (and the get_index_recommendations tool). "
356
358
  + "Pass `avgExecutionTimeMs` to anchor the impact estimate to a real baseline.",
357
359
  inputSchema: {
358
360
  type: "object",
@@ -1570,7 +1572,11 @@ function buildToolResult(name, payload, extra = {}) {
1570
1572
  text: summary,
1571
1573
  },
1572
1574
  ],
1573
- structuredContent: payload,
1575
+ // MCP spec requires `structuredContent` to be a JSON object. Several tools
1576
+ // (list_connections, get_relationships, list_business_rules, …) return a
1577
+ // top-level array from the backend; wrap those so spec-strict clients
1578
+ // (e.g. the `mcp` Python SDK used by Hermes) don't reject the result.
1579
+ structuredContent: Array.isArray(payload) ? { items: payload } : payload,
1574
1580
  };
1575
1581
  }
1576
1582
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.19.0",
4
- "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
3
+ "version": "0.20.0",
4
+ "description": "DeepSQL CLI, DBA Agent TUI, and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "bin/deepsql.js",
7
7
  "deepsql-mcp": "deepsql-phase1-server.js"
@@ -14,6 +14,7 @@
14
14
  "bin",
15
15
  "skills",
16
16
  "src",
17
+ "agent-profile",
17
18
  "deepsql-phase1-server.js",
18
19
  "deepsql-phase1-lib.js",
19
20
  "claude_desktop_config.customer.example.json",
@@ -98,7 +98,7 @@ usually doesn't know about either; that's exactly why DeepSQL exists.
98
98
  | `get_slow_query_customers(connectionId)` | Tenants ranked by total slow-query time — answers "which customer is driving the load?" |
99
99
  | `get_query_samples(connectionId, fingerprint)` | Literal SQL samples with bind values for a fingerprint, slowest-first. Use to reproduce an execution or run a real EXPLAIN. |
100
100
  | `get_slow_query_insights(connectionId, kind?, window?, limit?)` | Pre-computed AI insights — `hotspots`, `remediation`, `tail-risk`, `plan-drift`, `skew`, or `all` (default). |
101
- | `optimize_slow_query(connectionId, queryText, avgExecutionTimeMs?)` | AI optimization recommendations for a specific SQL — index suggestions, query rewrites, estimated impact. |
101
+ | `optimize_slow_query(connectionId, queryText, avgExecutionTimeMs?)` | AI query REWRITE + plan diagnosis for one SQL (single-query scoped). NOT indexes those need the whole workload; use `get_index_recommendations` or Workload Analysis. |
102
102
  | `get_table_growth(connectionId, tableName?, days?)` | Persistent stats history: per-table size/row time series + headline rollups. Use to answer "which tables are growing fastest?" or "how much has X grown in the last month?" without scanning the live DB. |
103
103
  | `get_growth_anomalies(connectionId, tableName?, unacknowledgedOnly?, days?)` | DeepSQL-flagged sudden growth spikes with severity (CRITICAL/WARNING/INFO), anomaly type, before/after sizes, confidence score. Check this BEFORE walking the user through a slow-query plan — a recent growth anomaly is often the real root cause. |
104
104
  | `execute_sql(connectionId, query, ...)` | Run any SQL — SELECT for everyone, DML/DDL for admins (two-step confirm). |
@@ -138,10 +138,11 @@ called by claude-code via deepsql CLI").
138
138
  deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
139
139
  ```
140
140
 
141
- ### Command catalog (19 top-level commands)
141
+ ### Command catalog (20 top-level commands)
142
142
 
143
143
  | Command | What it does | MCP equivalent? |
144
144
  |---|---|---|
145
+ | `deepsql agent` (or bare `deepsql` in a terminal) | Launch the **DeepSQL Agent** — an interactive DBA/BI chat TUI. Uses your saved `deepsql login`; the model is proxied by the DeepSQL backend, so no LLM key is needed. First run installs the agent runtime. | none — interactive TUI |
145
146
  | `deepsql login` | Authorize CLI against a DeepSQL host (browser PKCE / device code / password) | none — interactive only |
146
147
  | `deepsql logout` | Revoke the saved token | none |
147
148
  | `deepsql whoami` | Show the logged-in user, role, URL, pinned connection | none |
@@ -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 (rawArgv.length === 0 || isHelpFlag(rawArgv[0])) {
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.
@@ -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
- cfg = await findConnectionRecord(session, connectionId);
337
- if (!cfg) throw new Error(`Connection ${target} not found.`);
338
- // Backend's POST /connections/test uses saved secrets when `id` is set.
339
- cfg.id = connectionId;
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 || "", 14),
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 || "", 14),
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` : "—",