@deepsql/mcp 0.13.4 → 0.14.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 +20 -6
- package/deepsql-phase1-lib.js +178 -0
- package/package.json +1 -1
- package/skills/SKILL_BODY.md +169 -33
- package/src/cli.js +34 -0
- package/src/commands/index-recommendations.js +426 -0
- package/src/commands/index-recommendations.test.js +323 -0
- package/src/commands/login.js +46 -7
- package/src/commands/login.test.js +111 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
// Mock api/client.request and the connection resolver before requiring the
|
|
7
|
+
// command module so its captured references point at our stubs.
|
|
8
|
+
const apiClientPath = require.resolve("../api/client");
|
|
9
|
+
const realApiClient = require("../api/client");
|
|
10
|
+
const connectionsPath = require.resolve("./_connections");
|
|
11
|
+
const realConnections = require("./_connections");
|
|
12
|
+
const sessionPath = require.resolve("./_session");
|
|
13
|
+
const realSession = require("./_session");
|
|
14
|
+
|
|
15
|
+
function withMocks({ request, resolveConnectionId, resolveSession }, fn) {
|
|
16
|
+
delete require.cache[require.resolve("./index-recommendations")];
|
|
17
|
+
if (request) {
|
|
18
|
+
require.cache[apiClientPath] = {
|
|
19
|
+
...require.cache[apiClientPath],
|
|
20
|
+
exports: { ...realApiClient, request },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (resolveConnectionId) {
|
|
24
|
+
require.cache[connectionsPath] = {
|
|
25
|
+
...require.cache[connectionsPath],
|
|
26
|
+
exports: { ...realConnections, resolveConnectionId },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (resolveSession) {
|
|
30
|
+
require.cache[sessionPath] = {
|
|
31
|
+
...require.cache[sessionPath],
|
|
32
|
+
exports: { ...realSession, resolveSession },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const mod = require("./index-recommendations");
|
|
37
|
+
return fn(mod);
|
|
38
|
+
} finally {
|
|
39
|
+
require.cache[apiClientPath].exports = realApiClient;
|
|
40
|
+
require.cache[connectionsPath].exports = realConnections;
|
|
41
|
+
require.cache[sessionPath].exports = realSession;
|
|
42
|
+
delete require.cache[require.resolve("./index-recommendations")];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captured() {
|
|
47
|
+
const out = { stdout: "", stderr: "" };
|
|
48
|
+
const io = {
|
|
49
|
+
stdout: { write: (s) => (out.stdout += s) },
|
|
50
|
+
stderr: { write: (s) => (out.stderr += s) },
|
|
51
|
+
};
|
|
52
|
+
return { out, io };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fakeSession = () => ({ baseUrl: "http://x", token: "t", defaultConnection: null });
|
|
56
|
+
const fakeResolveConnId = async () => "conn-abc";
|
|
57
|
+
|
|
58
|
+
// ─── `top` ─────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
test("top prints workload-weighted summary lines with net-benefit + evidence", async () => {
|
|
61
|
+
await withMocks(
|
|
62
|
+
{
|
|
63
|
+
resolveSession: fakeSession,
|
|
64
|
+
resolveConnectionId: fakeResolveConnId,
|
|
65
|
+
request: async (baseUrl, path, opts) => {
|
|
66
|
+
assert.equal(path, "/index-recommendations/conn-abc/top");
|
|
67
|
+
assert.deepEqual(opts.query, { limit: 5 });
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
id: "rec-1",
|
|
71
|
+
tableName: "orders",
|
|
72
|
+
columnNames: "customer_id,status",
|
|
73
|
+
indexName: "idx_orders_customer_id_status",
|
|
74
|
+
kind: "CREATE_INDEX",
|
|
75
|
+
priority: "HIGH",
|
|
76
|
+
occurrenceCount: 4,
|
|
77
|
+
netBenefitMs: 4823000,
|
|
78
|
+
workloadScoreMs: 4823000,
|
|
79
|
+
writeCostScore: 142000,
|
|
80
|
+
evidenceCount: 3,
|
|
81
|
+
reason: "Workload-weighted composite from 3 contributing queries.",
|
|
82
|
+
hypopgBeforeCost: 1000,
|
|
83
|
+
hypopgAfterCost: 250,
|
|
84
|
+
hypopgReductionPct: 75,
|
|
85
|
+
topEvidence: [
|
|
86
|
+
{ calls: 4500, meanExecTimeMs: 850, totalExecTimeMs: 3825000, role: "WHERE_EQ" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
async (mod) => {
|
|
93
|
+
const { out, io } = captured();
|
|
94
|
+
await mod.run({ positional: ["top"], connection: "mylocalpg" }, io);
|
|
95
|
+
assert.match(out.stdout, /\[CREATE\] orders\(customer_id,status\)/);
|
|
96
|
+
assert.match(out.stdout, /seen 4×/);
|
|
97
|
+
assert.match(out.stdout, /net=1\.3h saved/);
|
|
98
|
+
assert.match(out.stdout, /HypoPG cost: 1000 → 250 \(−75\.0%\)/);
|
|
99
|
+
assert.match(out.stdout, /id: rec-1/);
|
|
100
|
+
assert.match(out.stdout, /top evidence: 4500 calls/);
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("top renders an empty-state message when no recommendations exist", async () => {
|
|
106
|
+
await withMocks(
|
|
107
|
+
{
|
|
108
|
+
resolveSession: fakeSession,
|
|
109
|
+
resolveConnectionId: fakeResolveConnId,
|
|
110
|
+
request: async () => [],
|
|
111
|
+
},
|
|
112
|
+
async (mod) => {
|
|
113
|
+
const { out, io } = captured();
|
|
114
|
+
await mod.run({ positional: ["top"], connection: "mylocalpg" }, io);
|
|
115
|
+
assert.match(out.stdout, /No pending index recommendations/);
|
|
116
|
+
assert.match(out.stdout, /index-recommendations refresh/);
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("top respects --limit and clamps to the [1, 50] range", async () => {
|
|
122
|
+
let captured_limit = null;
|
|
123
|
+
await withMocks(
|
|
124
|
+
{
|
|
125
|
+
resolveSession: fakeSession,
|
|
126
|
+
resolveConnectionId: fakeResolveConnId,
|
|
127
|
+
request: async (_, __, opts) => {
|
|
128
|
+
captured_limit = opts.query.limit;
|
|
129
|
+
return [];
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
async (mod) => {
|
|
133
|
+
const { io } = captured();
|
|
134
|
+
await mod.run({ positional: ["top"], connection: "c", limit: 999 }, io);
|
|
135
|
+
assert.equal(captured_limit, 50);
|
|
136
|
+
await mod.run({ positional: ["top"], connection: "c", limit: 0 }, io);
|
|
137
|
+
assert.equal(captured_limit, 1);
|
|
138
|
+
await mod.run({ positional: ["top"], connection: "c", limit: 10 }, io);
|
|
139
|
+
assert.equal(captured_limit, 10);
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("top --json passes through the raw payload", async () => {
|
|
145
|
+
await withMocks(
|
|
146
|
+
{
|
|
147
|
+
resolveSession: fakeSession,
|
|
148
|
+
resolveConnectionId: fakeResolveConnId,
|
|
149
|
+
request: async () => [{ id: "rec-1", tableName: "orders" }],
|
|
150
|
+
},
|
|
151
|
+
async (mod) => {
|
|
152
|
+
const { out, io } = captured();
|
|
153
|
+
await mod.run({ positional: ["top"], connection: "c", json: true }, io);
|
|
154
|
+
const parsed = JSON.parse(out.stdout);
|
|
155
|
+
assert.equal(parsed[0].id, "rec-1");
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── `apply` safety guard ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
test("apply with mode=apply but no --confirm refuses up-front (no API call)", async () => {
|
|
163
|
+
let called = false;
|
|
164
|
+
await withMocks(
|
|
165
|
+
{
|
|
166
|
+
resolveSession: fakeSession,
|
|
167
|
+
request: async () => {
|
|
168
|
+
called = true;
|
|
169
|
+
return {};
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
async (mod) => {
|
|
173
|
+
const { io } = captured();
|
|
174
|
+
await assert.rejects(
|
|
175
|
+
() => mod.run({ positional: ["apply", "rec-1"], mode: "apply" }, io),
|
|
176
|
+
/Re-run with --confirm/
|
|
177
|
+
);
|
|
178
|
+
assert.equal(called, false, "API should not be hit without --confirm");
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("apply DRY_RUN does not require --confirm and surfaces planner-cost delta", async () => {
|
|
184
|
+
await withMocks(
|
|
185
|
+
{
|
|
186
|
+
resolveSession: fakeSession,
|
|
187
|
+
request: async (_, path, opts) => {
|
|
188
|
+
assert.equal(path, "/index-recommendations/rec-1/apply");
|
|
189
|
+
assert.equal(opts.method, "POST");
|
|
190
|
+
assert.deepEqual(opts.query, { mode: "DRY_RUN", confirm: false, concurrent: true });
|
|
191
|
+
return {
|
|
192
|
+
recommendationId: "rec-1",
|
|
193
|
+
executedDdl: "CREATE INDEX idx_o_status ON orders (status);",
|
|
194
|
+
mode: "DRY_RUN",
|
|
195
|
+
status: "OK",
|
|
196
|
+
beforeCost: 1000,
|
|
197
|
+
afterCost: 250,
|
|
198
|
+
costReductionPct: 75,
|
|
199
|
+
samples: [
|
|
200
|
+
{ fingerprint: "abc123def456", beforeCost: 1000, afterCost: 250 },
|
|
201
|
+
],
|
|
202
|
+
message: "DRY_RUN complete — planner cost −75.0% (1000 → 250)",
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
async (mod) => {
|
|
207
|
+
const { out, io } = captured();
|
|
208
|
+
await mod.run({ positional: ["apply", "rec-1"], mode: "dry-run" }, io);
|
|
209
|
+
assert.match(out.stdout, /\[DRY_RUN\] OK/);
|
|
210
|
+
assert.match(out.stdout, /planner cost: 1000 → 250 \(−75\.0%\)/);
|
|
211
|
+
assert.match(out.stdout, /fp=abc123def456 cost 1000 → 250/);
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("apply --no-concurrent passes concurrent=false to the backend", async () => {
|
|
217
|
+
let captured_query = null;
|
|
218
|
+
await withMocks(
|
|
219
|
+
{
|
|
220
|
+
resolveSession: fakeSession,
|
|
221
|
+
request: async (_, __, opts) => {
|
|
222
|
+
captured_query = opts.query;
|
|
223
|
+
return { mode: "APPLY", status: "OK", executedDdl: "CREATE INDEX …" };
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
async (mod) => {
|
|
227
|
+
const { io } = captured();
|
|
228
|
+
await mod.run(
|
|
229
|
+
{
|
|
230
|
+
positional: ["apply", "rec-1"],
|
|
231
|
+
mode: "apply",
|
|
232
|
+
confirm: true,
|
|
233
|
+
noConcurrent: true,
|
|
234
|
+
},
|
|
235
|
+
io
|
|
236
|
+
);
|
|
237
|
+
assert.deepEqual(captured_query, { mode: "APPLY", confirm: true, concurrent: false });
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("apply rejects unknown modes early", async () => {
|
|
243
|
+
await withMocks(
|
|
244
|
+
{
|
|
245
|
+
resolveSession: fakeSession,
|
|
246
|
+
request: async () => {
|
|
247
|
+
throw new Error("API should not be called");
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
async (mod) => {
|
|
251
|
+
const { io } = captured();
|
|
252
|
+
await assert.rejects(
|
|
253
|
+
() => mod.run({ positional: ["apply", "rec-1"], mode: "bogus" }, io),
|
|
254
|
+
/Unknown mode/
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── refresh / dismiss ─────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
test("refresh POSTs to /generate and surfaces the count", async () => {
|
|
263
|
+
await withMocks(
|
|
264
|
+
{
|
|
265
|
+
resolveSession: fakeSession,
|
|
266
|
+
resolveConnectionId: fakeResolveConnId,
|
|
267
|
+
request: async (_, path, opts) => {
|
|
268
|
+
assert.equal(path, "/index-recommendations/generate/conn-abc");
|
|
269
|
+
assert.equal(opts.method, "POST");
|
|
270
|
+
return { success: true, count: 12, message: "Generated 12 index recommendations" };
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
async (mod) => {
|
|
274
|
+
const { out, io } = captured();
|
|
275
|
+
await mod.run({ positional: ["refresh"], connection: "mylocalpg" }, io);
|
|
276
|
+
assert.match(out.stdout, /Refresh complete: 12 candidate/);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("dismiss issues PUT /{id}/dismiss", async () => {
|
|
282
|
+
let called_path = null;
|
|
283
|
+
let called_method = null;
|
|
284
|
+
await withMocks(
|
|
285
|
+
{
|
|
286
|
+
resolveSession: fakeSession,
|
|
287
|
+
request: async (_, path, opts) => {
|
|
288
|
+
called_path = path;
|
|
289
|
+
called_method = opts.method;
|
|
290
|
+
return {};
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
async (mod) => {
|
|
294
|
+
const { out, io } = captured();
|
|
295
|
+
await mod.run({ positional: ["dismiss", "rec-9"] }, io);
|
|
296
|
+
assert.equal(called_path, "/index-recommendations/rec-9/dismiss");
|
|
297
|
+
assert.equal(called_method, "PUT");
|
|
298
|
+
assert.match(out.stdout, /Dismissed recommendation rec-9/);
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ─── usage errors ──────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
test("run with no subcommand prints usage error", async () => {
|
|
306
|
+
await withMocks({ resolveSession: fakeSession }, async (mod) => {
|
|
307
|
+
const { io } = captured();
|
|
308
|
+
await assert.rejects(
|
|
309
|
+
() => mod.run({ positional: [] }, io),
|
|
310
|
+
/Usage: deepsql index-recommendations/
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("run with unknown subcommand throws clearly", async () => {
|
|
316
|
+
await withMocks({ resolveSession: fakeSession }, async (mod) => {
|
|
317
|
+
const { io } = captured();
|
|
318
|
+
await assert.rejects(
|
|
319
|
+
() => mod.run({ positional: ["bogus"] }, io),
|
|
320
|
+
/Unknown index-recommendations subcommand: bogus/
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/commands/login.js
CHANGED
|
@@ -11,6 +11,50 @@ function defaultClientLabel() {
|
|
|
11
11
|
return `deepsql-cli@${process.versions.node}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolve which DeepSQL URL `deepsql login` should target. Self-hosted
|
|
16
|
+
* safety property: we never silently pick "the URL the user happens to
|
|
17
|
+
* have logged into first" when their machine has multiple profiles.
|
|
18
|
+
*
|
|
19
|
+
* Rules:
|
|
20
|
+
* --url given → use it (always wins).
|
|
21
|
+
* 0 saved profiles → error, ask for --url with a concrete example.
|
|
22
|
+
* 1 saved profile → use it, print which one so the user can ^C if it's
|
|
23
|
+
* not what they meant.
|
|
24
|
+
* ≥ 2 profiles → error, list them, require --url. This is the case
|
|
25
|
+
* that bit a user testing against multiple hosts —
|
|
26
|
+
* bare `deepsql login` used to keep returning to
|
|
27
|
+
* whichever URL was logged into FIRST, regardless
|
|
28
|
+
* of which one the user actually wanted.
|
|
29
|
+
*
|
|
30
|
+
* Exported for tests.
|
|
31
|
+
*/
|
|
32
|
+
function resolveLoginBaseUrl(opts, { stderr = process.stderr } = {}) {
|
|
33
|
+
if (opts.url) {
|
|
34
|
+
return store.normalizeBaseUrl(opts.url);
|
|
35
|
+
}
|
|
36
|
+
const state = store.listProfiles();
|
|
37
|
+
const urls = Object.keys(state.profiles || {});
|
|
38
|
+
if (urls.length === 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"No saved DeepSQL profile yet. Pass --url <https://your-deepsql-host> "
|
|
41
|
+
+ "with your own DeepSQL host (e.g. `deepsql login --url https://deepsql.acme.com`).",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (urls.length === 1) {
|
|
45
|
+
const only = urls[0];
|
|
46
|
+
stderr.write(
|
|
47
|
+
`[deepsql] Using saved profile: ${only}. Pass --url to log in against a different host.\n`,
|
|
48
|
+
);
|
|
49
|
+
return only;
|
|
50
|
+
}
|
|
51
|
+
// Multiple profiles — refuse to guess. List them so the user can pick.
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Multiple saved DeepSQL profiles. Pass --url <host> to choose one:\n - ${urls.join("\n - ")}\n`
|
|
54
|
+
+ `Or run \`deepsql config set-default <url>\` to pin one as the default for future logins.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
14
58
|
function pickFlow(opts) {
|
|
15
59
|
// Explicit user choice wins.
|
|
16
60
|
if (opts.password) return "password";
|
|
@@ -23,12 +67,7 @@ function pickFlow(opts) {
|
|
|
23
67
|
}
|
|
24
68
|
|
|
25
69
|
async function run(opts, { stderr = process.stderr, stdout = process.stdout } = {}) {
|
|
26
|
-
const baseUrl = opts
|
|
27
|
-
if (!baseUrl) {
|
|
28
|
-
throw new Error(
|
|
29
|
-
"Pass --url <https://your-deepsql> on first login (no default profile is saved yet).",
|
|
30
|
-
);
|
|
31
|
-
}
|
|
70
|
+
const baseUrl = resolveLoginBaseUrl(opts, { stderr });
|
|
32
71
|
const hostname = os.hostname();
|
|
33
72
|
const label = opts.label || defaultClientLabel();
|
|
34
73
|
const flow = pickFlow(opts);
|
|
@@ -78,4 +117,4 @@ async function run(opts, { stderr = process.stderr, stdout = process.stdout } =
|
|
|
78
117
|
stdout.write(`Token saved to ${store.authFilePath()}\n`);
|
|
79
118
|
}
|
|
80
119
|
|
|
81
|
-
module.exports = { run };
|
|
120
|
+
module.exports = { run, resolveLoginBaseUrl };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
// Stub the auth/store module before requiring login so login picks up
|
|
7
|
+
// our fake. Each test rebuilds the stub with the profile shape it wants.
|
|
8
|
+
function withStubbedStore(profiles, fn) {
|
|
9
|
+
const storeKey = require.resolve("../auth/store");
|
|
10
|
+
const loginKey = require.resolve("./login");
|
|
11
|
+
delete require.cache[storeKey];
|
|
12
|
+
delete require.cache[loginKey];
|
|
13
|
+
require.cache[storeKey] = {
|
|
14
|
+
id: storeKey,
|
|
15
|
+
filename: storeKey,
|
|
16
|
+
loaded: true,
|
|
17
|
+
exports: {
|
|
18
|
+
normalizeBaseUrl: (url) => url.endsWith("/") ? url : `${url}/`,
|
|
19
|
+
listProfiles: () => ({ profiles, default: Object.keys(profiles)[0] || null }),
|
|
20
|
+
// Other fns aren't used by resolveLoginBaseUrl. Stub them just so
|
|
21
|
+
// the require() in login.js doesn't blow up.
|
|
22
|
+
defaultBaseUrl: () => null,
|
|
23
|
+
getProfile: () => null,
|
|
24
|
+
setProfile: () => {},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const { resolveLoginBaseUrl } = require("./login");
|
|
28
|
+
try {
|
|
29
|
+
return fn(resolveLoginBaseUrl);
|
|
30
|
+
} finally {
|
|
31
|
+
delete require.cache[storeKey];
|
|
32
|
+
delete require.cache[loginKey];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function captureStderr() {
|
|
37
|
+
let buf = "";
|
|
38
|
+
return { write: (s) => { buf += s; }, get: () => buf };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── --url always wins ────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
test("login --url wins regardless of saved profiles", () => {
|
|
44
|
+
withStubbedStore(
|
|
45
|
+
{ "https://existing.example.com/": { token: "t" } },
|
|
46
|
+
(resolve) => {
|
|
47
|
+
const stderr = captureStderr();
|
|
48
|
+
const url = resolve({ url: "https://customer-fresh.example.com" }, { stderr });
|
|
49
|
+
assert.equal(url, "https://customer-fresh.example.com/");
|
|
50
|
+
assert.equal(stderr.get(), "", "no chatty hint when user passed --url explicitly");
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ─── 0 profiles: clean error with self-host example ───────────────────────
|
|
56
|
+
|
|
57
|
+
test("login with no --url and no saved profiles errors with a self-host-friendly hint", () => {
|
|
58
|
+
withStubbedStore({}, (resolve) => {
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => resolve({}, { stderr: captureStderr() }),
|
|
61
|
+
/Pass --url <https:\/\/your-deepsql-host>.*deepsql\.acme\.com/s,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── exactly 1 profile: use it, but announce which one ───────────────────
|
|
67
|
+
|
|
68
|
+
test("login with no --url and one saved profile uses it AND announces it on stderr", () => {
|
|
69
|
+
withStubbedStore(
|
|
70
|
+
{ "https://customer.example.com/": { token: "t" } },
|
|
71
|
+
(resolve) => {
|
|
72
|
+
const stderr = captureStderr();
|
|
73
|
+
const url = resolve({}, { stderr });
|
|
74
|
+
assert.equal(url, "https://customer.example.com/");
|
|
75
|
+
const hint = stderr.get();
|
|
76
|
+
assert.match(hint, /Using saved profile: https:\/\/customer\.example\.com\//);
|
|
77
|
+
assert.match(hint, /Pass --url to log in against a different host/);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── ≥ 2 profiles: refuse to guess (the regression we're fixing) ─────────
|
|
83
|
+
|
|
84
|
+
test("login with no --url and multiple profiles refuses to guess — lists them and requires --url", () => {
|
|
85
|
+
// This is the scenario that bit the user: they logged into our hosted
|
|
86
|
+
// demo (deepsql.stayflexi.com) AND their self-hosted instance, and bare
|
|
87
|
+
// `deepsql login` kept defaulting to whichever was logged in first.
|
|
88
|
+
// After this fix, the CLI lists both and requires --url.
|
|
89
|
+
withStubbedStore(
|
|
90
|
+
{
|
|
91
|
+
"https://deepsql.stayflexi.com/": { token: "t1" },
|
|
92
|
+
"https://customer.example.com/": { token: "t2" },
|
|
93
|
+
},
|
|
94
|
+
(resolve) => {
|
|
95
|
+
let thrown;
|
|
96
|
+
try {
|
|
97
|
+
resolve({}, { stderr: captureStderr() });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
thrown = err;
|
|
100
|
+
}
|
|
101
|
+
assert.ok(thrown, "must throw when multiple profiles exist");
|
|
102
|
+
assert.match(thrown.message, /Multiple saved DeepSQL profiles/);
|
|
103
|
+
assert.match(thrown.message, /Pass --url <host> to choose one/);
|
|
104
|
+
// Both URLs must be listed so the user can pick.
|
|
105
|
+
assert.match(thrown.message, /https:\/\/deepsql\.stayflexi\.com\//);
|
|
106
|
+
assert.match(thrown.message, /https:\/\/customer\.example\.com\//);
|
|
107
|
+
// And we point them at config set-default for pinning.
|
|
108
|
+
assert.match(thrown.message, /deepsql config set-default/);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
});
|