@ddt-tools/cli 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/LICENSE +201 -0
- package/README.md +68 -0
- package/dist/cli.cjs +19298 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +19467 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1288 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +164 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +1248 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
// src/commands/connection.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
createConnection,
|
|
5
|
+
defaultProfilesPath,
|
|
6
|
+
getProfile,
|
|
7
|
+
loadProfiles,
|
|
8
|
+
removeProfile,
|
|
9
|
+
upsertProfile
|
|
10
|
+
} from "@ddt-tools/core";
|
|
11
|
+
function connectionCommand() {
|
|
12
|
+
const cmd = new Command("connection").description("Manage Databricks connection profiles.");
|
|
13
|
+
cmd.command("add <name>").description("Store a new connection profile.").option("--host <host>", "Workspace host, e.g. dbc-12345-abcd.cloud.databricks.com").option("--auth <method>", "One of: pat | oauth-m2m | oauth-u2m | azure-ad | google-idc", "pat").option(
|
|
14
|
+
"--token <token>",
|
|
15
|
+
"PAT token \u2014 use env:DATABRICKS_TOKEN to reference an env var, never inline a secret"
|
|
16
|
+
).option("--client-id <id>", "OAuth M2M / Azure AD client ID").option("--client-secret <secret>", "OAuth M2M / Azure AD client secret").option("--tenant-id <id>", "Azure AD tenant ID").option(
|
|
17
|
+
"--service-account-key-path <path>",
|
|
18
|
+
"Path to a GCP service-account key JSON file (for google-idc auth)"
|
|
19
|
+
).requiredOption("--warehouse-id <id>", "SQL warehouse ID for DDL operations").option("--catalog <catalog>", "Default Unity Catalog catalog").option("--schema <schema>", "Default schema").action(async (name, opts) => {
|
|
20
|
+
if (!opts.host) throw new Error("--host is required");
|
|
21
|
+
const auth = buildAuth(opts);
|
|
22
|
+
const profile = {
|
|
23
|
+
name,
|
|
24
|
+
platform: "Databricks",
|
|
25
|
+
auth,
|
|
26
|
+
warehouseId: String(opts.warehouseId),
|
|
27
|
+
...opts.catalog ? { catalog: String(opts.catalog) } : {},
|
|
28
|
+
...opts.schema ? { schema: String(opts.schema) } : {}
|
|
29
|
+
};
|
|
30
|
+
await upsertProfile(profile);
|
|
31
|
+
console.log(`Saved profile "${name}" \u2192 ${defaultProfilesPath()}`);
|
|
32
|
+
});
|
|
33
|
+
cmd.command("list").description("List configured Databricks profiles.").action(async () => {
|
|
34
|
+
const profiles = await loadProfiles();
|
|
35
|
+
if (profiles.length === 0) {
|
|
36
|
+
console.log(`(no profiles configured at ${defaultProfilesPath()})`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const p of profiles) {
|
|
40
|
+
console.log(`${p.name} ${p.auth.method} ${p.auth.host} warehouse=${p.warehouseId}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
cmd.command("get <name>").description("Print a profile (secrets are redacted).").action(async (name) => {
|
|
44
|
+
const p = await getProfile(name);
|
|
45
|
+
const redacted = {
|
|
46
|
+
...p,
|
|
47
|
+
auth: redactAuth(p.auth)
|
|
48
|
+
};
|
|
49
|
+
console.log(JSON.stringify(redacted, null, 2));
|
|
50
|
+
});
|
|
51
|
+
cmd.command("remove <name>").description("Delete a connection profile.").action(async (name) => {
|
|
52
|
+
const removed = await removeProfile(name);
|
|
53
|
+
console.log(removed ? `Removed profile "${name}".` : `No profile "${name}" found.`);
|
|
54
|
+
if (!removed) process.exitCode = 1;
|
|
55
|
+
});
|
|
56
|
+
cmd.command("test <name>").description(
|
|
57
|
+
"Probe a connection profile via the workspace REST API (`/api/2.0/preview/scim/v2/Me`)."
|
|
58
|
+
).action(async (name) => {
|
|
59
|
+
const profile = await getProfile(name);
|
|
60
|
+
const conn = createConnection(profile);
|
|
61
|
+
try {
|
|
62
|
+
await conn.connect();
|
|
63
|
+
const info = await conn.test();
|
|
64
|
+
console.log(`OK ${name}`);
|
|
65
|
+
console.log(` host: ${profile.auth.host}`);
|
|
66
|
+
console.log(` account: ${info.account}`);
|
|
67
|
+
console.log(` version: ${info.version}`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`FAIL ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
} finally {
|
|
72
|
+
await conn.disconnect();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return cmd;
|
|
76
|
+
}
|
|
77
|
+
function buildAuth(opts) {
|
|
78
|
+
const host = String(opts.host);
|
|
79
|
+
const method = String(opts.auth ?? "pat").toLowerCase();
|
|
80
|
+
switch (method) {
|
|
81
|
+
case "pat":
|
|
82
|
+
case "personal_access_token":
|
|
83
|
+
if (!opts.token) throw new Error("--token is required for PAT auth");
|
|
84
|
+
return { method: "PERSONAL_ACCESS_TOKEN", host, token: String(opts.token) };
|
|
85
|
+
case "oauth-m2m":
|
|
86
|
+
case "oauth_m2m":
|
|
87
|
+
if (!opts.clientId || !opts.clientSecret) {
|
|
88
|
+
throw new Error("--client-id and --client-secret are required for OAuth M2M");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
method: "OAUTH_M2M",
|
|
92
|
+
host,
|
|
93
|
+
clientId: String(opts.clientId),
|
|
94
|
+
clientSecret: String(opts.clientSecret)
|
|
95
|
+
};
|
|
96
|
+
case "oauth-u2m":
|
|
97
|
+
case "oauth_u2m":
|
|
98
|
+
return { method: "OAUTH_U2M", host };
|
|
99
|
+
case "azure-ad":
|
|
100
|
+
case "azure_ad":
|
|
101
|
+
if (!opts.tenantId || !opts.clientId) {
|
|
102
|
+
throw new Error("--tenant-id and --client-id are required for Azure AD");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
method: "AZURE_AD",
|
|
106
|
+
host,
|
|
107
|
+
tenantId: String(opts.tenantId),
|
|
108
|
+
clientId: String(opts.clientId),
|
|
109
|
+
...opts.clientSecret ? { clientSecret: String(opts.clientSecret) } : {}
|
|
110
|
+
};
|
|
111
|
+
case "google-idc":
|
|
112
|
+
case "google_idc":
|
|
113
|
+
if (!opts.serviceAccountKeyPath) {
|
|
114
|
+
throw new Error("--service-account-key-path is required for google-idc auth");
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
method: "GOOGLE_IDC",
|
|
118
|
+
host,
|
|
119
|
+
serviceAccountKeyPath: String(opts.serviceAccountKeyPath)
|
|
120
|
+
};
|
|
121
|
+
default:
|
|
122
|
+
throw new Error(`Unknown --auth value: ${method}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function redactAuth(auth) {
|
|
126
|
+
if (auth.method === "PERSONAL_ACCESS_TOKEN" && auth.token && !auth.token.startsWith("env:")) {
|
|
127
|
+
return { ...auth, token: "<redacted>" };
|
|
128
|
+
}
|
|
129
|
+
if (auth.method === "OAUTH_M2M" && auth.clientSecret && !auth.clientSecret.startsWith("env:")) {
|
|
130
|
+
return { ...auth, clientSecret: "<redacted>" };
|
|
131
|
+
}
|
|
132
|
+
if (auth.method === "AZURE_AD" && auth.clientSecret && !auth.clientSecret.startsWith("env:")) {
|
|
133
|
+
return { ...auth, clientSecret: "<redacted>" };
|
|
134
|
+
}
|
|
135
|
+
return auth;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/commands/scaffold.ts
|
|
139
|
+
import { Command as Command2 } from "commander";
|
|
140
|
+
function scaffoldCommand(name) {
|
|
141
|
+
return new Command2(name).description(`${name} \u2014 Databricks (scaffold; later-release deliverable).`).action(() => {
|
|
142
|
+
console.error(
|
|
143
|
+
`ddt ${name}: scaffold \u2014 the Databricks engine for this command is a later deliverable.
|
|
144
|
+
Track progress at Databricks/docs/ROADMAP.md.`
|
|
145
|
+
);
|
|
146
|
+
process.exitCode = 2;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/commands/safety.ts
|
|
151
|
+
import { Command as Command3 } from "commander";
|
|
152
|
+
import { safety as safetyApi } from "@ddt-tools/core";
|
|
153
|
+
function safetyCommand() {
|
|
154
|
+
const cmd = new Command3("safety");
|
|
155
|
+
cmd.description(
|
|
156
|
+
"Inspect the safety-finding catalog. See `ddt safety list` and `ddt safety explain <code>`."
|
|
157
|
+
);
|
|
158
|
+
cmd.command("list").description("List every known finding code with category + one-line summary.").option("--format <format>", "Output format: table | json", "table").option(
|
|
159
|
+
"--category <kind>",
|
|
160
|
+
"Filter to one category: unrecoverable | destructive | expensive | warning. Default: all."
|
|
161
|
+
).action((opts) => {
|
|
162
|
+
const codes = safetyApi.listFindingCodes();
|
|
163
|
+
let entries = codes.map((c) => safetyApi.explainFinding(c)).filter((e) => e !== void 0);
|
|
164
|
+
if (opts.category) {
|
|
165
|
+
const want = opts.category.toUpperCase();
|
|
166
|
+
const valid = ["UNRECOVERABLE", "DESTRUCTIVE", "EXPENSIVE", "WARNING"];
|
|
167
|
+
if (!valid.includes(want)) {
|
|
168
|
+
console.error(
|
|
169
|
+
`Unknown --category "${opts.category}". Use one of: ${valid.join(" | ").toLowerCase()}.`
|
|
170
|
+
);
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
entries = entries.filter((e) => e.category === want);
|
|
175
|
+
}
|
|
176
|
+
if ((opts.format ?? "table") === "json") {
|
|
177
|
+
process.stdout.write(JSON.stringify(entries, null, 2) + "\n");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (entries.length === 0) {
|
|
181
|
+
console.log("(no entries match the filter)");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
printList(entries);
|
|
185
|
+
});
|
|
186
|
+
cmd.command("explain").description('Expand a finding code into a deep "why this is dangerous" page.').argument("<code>", "The finding code (e.g. DROP_UNRECOVERABLE, COLUMN_TYPE_CHANGE)").option("--format <format>", "Output format: text | json | markdown", "text").action((codeArg, opts) => {
|
|
187
|
+
const code = codeArg.toUpperCase();
|
|
188
|
+
const entry = safetyApi.explainFinding(code);
|
|
189
|
+
if (!entry) {
|
|
190
|
+
console.error(`Unknown safety finding code: "${codeArg}"`);
|
|
191
|
+
console.error(" Try: ddt safety list");
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const format = (opts.format ?? "text").toLowerCase();
|
|
196
|
+
if (format === "json") {
|
|
197
|
+
process.stdout.write(JSON.stringify(entry, null, 2) + "\n");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (format === "markdown") {
|
|
201
|
+
process.stdout.write(renderMarkdown(entry) + "\n");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
printDeep(entry);
|
|
205
|
+
});
|
|
206
|
+
return cmd;
|
|
207
|
+
}
|
|
208
|
+
function printList(entries) {
|
|
209
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
const arr = byCategory.get(e.category) ?? [];
|
|
212
|
+
arr.push(e);
|
|
213
|
+
byCategory.set(e.category, arr);
|
|
214
|
+
}
|
|
215
|
+
const order = [
|
|
216
|
+
"UNRECOVERABLE",
|
|
217
|
+
"DESTRUCTIVE",
|
|
218
|
+
"EXPENSIVE",
|
|
219
|
+
"WARNING"
|
|
220
|
+
];
|
|
221
|
+
for (const cat of order) {
|
|
222
|
+
const arr = byCategory.get(cat);
|
|
223
|
+
if (!arr || arr.length === 0) continue;
|
|
224
|
+
console.log(`# ${cat} (${arr.length})`);
|
|
225
|
+
for (const e of arr) {
|
|
226
|
+
console.log(` ${e.code}`);
|
|
227
|
+
console.log(` ${e.title}`);
|
|
228
|
+
console.log(` ${e.summary}`);
|
|
229
|
+
}
|
|
230
|
+
console.log("");
|
|
231
|
+
}
|
|
232
|
+
console.log("Use `ddt safety explain <code>` for the full per-finding write-up.");
|
|
233
|
+
}
|
|
234
|
+
function printDeep(entry) {
|
|
235
|
+
console.log(`${entry.code} [${entry.category}]`);
|
|
236
|
+
console.log(entry.title);
|
|
237
|
+
console.log("");
|
|
238
|
+
console.log("Summary");
|
|
239
|
+
console.log(` ${entry.summary}`);
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log("Why this is dangerous");
|
|
242
|
+
for (const line of wrap(entry.whyDangerous, 76)) {
|
|
243
|
+
console.log(` ${line}`);
|
|
244
|
+
}
|
|
245
|
+
console.log("");
|
|
246
|
+
console.log("What cannot be reversed");
|
|
247
|
+
for (const item of entry.cannotBeReversed) console.log(` - ${item}`);
|
|
248
|
+
console.log("");
|
|
249
|
+
console.log("Safer alternatives");
|
|
250
|
+
for (const item of entry.saferAlternatives) console.log(` - ${item}`);
|
|
251
|
+
console.log("");
|
|
252
|
+
if (entry.requiredGates.length > 0) {
|
|
253
|
+
console.log("Required gates to allow this finding through");
|
|
254
|
+
for (const g of entry.requiredGates) console.log(` - ${g}`);
|
|
255
|
+
console.log("");
|
|
256
|
+
}
|
|
257
|
+
if (entry.example) {
|
|
258
|
+
console.log("Example");
|
|
259
|
+
console.log(` ${entry.example}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function renderMarkdown(entry) {
|
|
263
|
+
const lines = [];
|
|
264
|
+
lines.push(`## ${entry.code} \u2014 ${entry.title}`);
|
|
265
|
+
lines.push("");
|
|
266
|
+
lines.push(`**Category:** ${entry.category}`);
|
|
267
|
+
lines.push("");
|
|
268
|
+
lines.push(`**Summary:** ${entry.summary}`);
|
|
269
|
+
lines.push("");
|
|
270
|
+
lines.push("### Why this is dangerous");
|
|
271
|
+
lines.push(entry.whyDangerous);
|
|
272
|
+
lines.push("");
|
|
273
|
+
lines.push("### What cannot be reversed");
|
|
274
|
+
for (const item of entry.cannotBeReversed) lines.push(`- ${item}`);
|
|
275
|
+
lines.push("");
|
|
276
|
+
lines.push("### Safer alternatives");
|
|
277
|
+
for (const item of entry.saferAlternatives) lines.push(`- ${item}`);
|
|
278
|
+
if (entry.requiredGates.length > 0) {
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push("### Required gates");
|
|
281
|
+
for (const g of entry.requiredGates) lines.push(`- \`${g}\``);
|
|
282
|
+
}
|
|
283
|
+
if (entry.example) {
|
|
284
|
+
lines.push("");
|
|
285
|
+
lines.push("### Example");
|
|
286
|
+
lines.push("```");
|
|
287
|
+
lines.push(entry.example);
|
|
288
|
+
lines.push("```");
|
|
289
|
+
}
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
292
|
+
function wrap(text, width) {
|
|
293
|
+
const words = text.split(/\s+/);
|
|
294
|
+
const lines = [];
|
|
295
|
+
let current = "";
|
|
296
|
+
for (const word of words) {
|
|
297
|
+
if (current.length === 0) {
|
|
298
|
+
current = word;
|
|
299
|
+
} else if (current.length + 1 + word.length <= width) {
|
|
300
|
+
current += " " + word;
|
|
301
|
+
} else {
|
|
302
|
+
lines.push(current);
|
|
303
|
+
current = word;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (current.length > 0) lines.push(current);
|
|
307
|
+
return lines;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/commands/safer-alternative.ts
|
|
311
|
+
import { promises as fs } from "fs";
|
|
312
|
+
import { Command as Command4 } from "commander";
|
|
313
|
+
import { ai, saferAlternative } from "@ddt-tools/core";
|
|
314
|
+
function saferAlternativeCommand() {
|
|
315
|
+
const cmd = new Command4("safer-alternative");
|
|
316
|
+
cmd.description(
|
|
317
|
+
"AI-assist: propose a safer DDL alternative for a safety finding. Requires a configured AI provider (ddt ai status)."
|
|
318
|
+
).requiredOption("--code <code>", "SafetyFindingCode (e.g. COLUMN_DROP, DROP_UNRECOVERABLE).").requiredOption("--fqn <fqn>", "Object FQN the finding refers to.").requiredOption("--object-type <type>", "Object type (e.g. MANAGED_TABLE, VIEW, FUNCTION).").requiredOption("--reason <text>", "Short reason text from the safety classifier.").option(
|
|
319
|
+
"--gate <gate>",
|
|
320
|
+
"Optional SafetyGate the finding raised (e.g. REQUIRE_ALLOW_DROP_COLUMN)."
|
|
321
|
+
).option("--sql <path>", 'Path to a file with the dangerous DDL. Use "-" to read from stdin.').option("--context <path>", "Optional path to the surrounding source DDL.").option("--intent <text>", "Optional human-authored intent notes.").option("--format <fmt>", "Output format: text | json. Default text.", "text").option(
|
|
322
|
+
"--ai-max-spend <usd>",
|
|
323
|
+
"Refuse the call if today's estimated spend \u2265 this (USD). 0 = no cap.",
|
|
324
|
+
"0"
|
|
325
|
+
).action(async (opts) => {
|
|
326
|
+
const dangerousSql = await readInput(
|
|
327
|
+
opts.sql,
|
|
328
|
+
'--sql is required (use a path or "-" for stdin).'
|
|
329
|
+
);
|
|
330
|
+
const contextSql = opts.context ? await fs.readFile(String(opts.context), "utf8") : void 0;
|
|
331
|
+
const finding = {
|
|
332
|
+
code: String(opts.code),
|
|
333
|
+
category: "DESTRUCTIVE",
|
|
334
|
+
fqn: String(opts.fqn),
|
|
335
|
+
objectType: String(opts.objectType),
|
|
336
|
+
reason: String(opts.reason),
|
|
337
|
+
gate: opts.gate ? String(opts.gate) : void 0
|
|
338
|
+
};
|
|
339
|
+
const result = await saferAlternative.suggestSaferAlternative(
|
|
340
|
+
{
|
|
341
|
+
finding,
|
|
342
|
+
dangerousSql,
|
|
343
|
+
contextSql,
|
|
344
|
+
intentNotes: opts.intent ? String(opts.intent) : void 0
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
completeFn: async (prompt) => {
|
|
348
|
+
const r = await ai.complete([{ role: "user", content: prompt }], {
|
|
349
|
+
feature: "safer-alternative",
|
|
350
|
+
maxSpendUsd: Number(opts.aiMaxSpend ?? "0") || 0
|
|
351
|
+
});
|
|
352
|
+
return r.text;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
if (String(opts.format).toLowerCase() === "json") {
|
|
357
|
+
const { rawModelText: _omit, ...keep } = result;
|
|
358
|
+
console.log(JSON.stringify(keep, null, 2));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
console.log(`Confidence: ${result.confidence}${result.parseFailed ? " (parse failed)" : ""}`);
|
|
362
|
+
console.log(`Reasoning: ${result.reasoning}`);
|
|
363
|
+
if (result.alternativeSql) {
|
|
364
|
+
console.log("");
|
|
365
|
+
console.log("--- Proposed safer DDL ---");
|
|
366
|
+
console.log(result.alternativeSql);
|
|
367
|
+
} else {
|
|
368
|
+
console.warn("No safer alternative SQL was returned.");
|
|
369
|
+
}
|
|
370
|
+
if (result.requiredGates.length > 0) {
|
|
371
|
+
console.log(`Required gates: ${result.requiredGates.join(", ")}`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return cmd;
|
|
375
|
+
}
|
|
376
|
+
async function readInput(pathOrDash, missingMessage) {
|
|
377
|
+
if (!pathOrDash) throw new Error(missingMessage);
|
|
378
|
+
const p = String(pathOrDash);
|
|
379
|
+
if (p === "-") {
|
|
380
|
+
const chunks = [];
|
|
381
|
+
for await (const chunk of process.stdin) {
|
|
382
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
383
|
+
}
|
|
384
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
385
|
+
}
|
|
386
|
+
return fs.readFile(p, "utf8");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/commands/sketch.ts
|
|
390
|
+
import { promises as fs2 } from "fs";
|
|
391
|
+
import { Command as Command5 } from "commander";
|
|
392
|
+
import { ai as ai2, objectSketch } from "@ddt-tools/core";
|
|
393
|
+
var KINDS = [
|
|
394
|
+
"managed-table",
|
|
395
|
+
"external-table",
|
|
396
|
+
"streaming-table",
|
|
397
|
+
"materialized-view",
|
|
398
|
+
"view",
|
|
399
|
+
"function",
|
|
400
|
+
"volume"
|
|
401
|
+
];
|
|
402
|
+
function sketchCommand() {
|
|
403
|
+
const cmd = new Command5("sketch");
|
|
404
|
+
cmd.description(
|
|
405
|
+
"AI-assist: scaffold idiomatic Databricks UC DDL from a prose description. Output always carries a REVIEW BEFORE DEPLOY header."
|
|
406
|
+
).argument("<kind>", `Object kind: ${KINDS.join(" | ")}`).requiredOption("--description <text>", 'Free-form description. Use "-" to read from stdin.').option("--target <fqn>", "Target FQN (e.g. analytics.public.orders).").option("--context <text>", 'Optional additional context (e.g. "use SQL warehouse small").').option("--out <path>", "Output file. Default stdout.").option("--format <fmt>", "Output format: text | json. Default text.", "text").option(
|
|
407
|
+
"--ai-max-spend <usd>",
|
|
408
|
+
"Refuse the call if today's estimated spend \u2265 this (USD). 0 = no cap.",
|
|
409
|
+
"0"
|
|
410
|
+
).action(async (kindArg, opts) => {
|
|
411
|
+
const kind = String(kindArg).toLowerCase();
|
|
412
|
+
if (!KINDS.includes(kind)) {
|
|
413
|
+
throw new Error(`Unknown kind "${kindArg}". Use one of: ${KINDS.join(" | ")}`);
|
|
414
|
+
}
|
|
415
|
+
const description = String(opts.description) === "-" ? await readStdin() : String(opts.description);
|
|
416
|
+
const targetFqn = opts.target ? splitFqn(String(opts.target)) : void 0;
|
|
417
|
+
const result = await objectSketch.sketchToDdl(
|
|
418
|
+
{
|
|
419
|
+
description,
|
|
420
|
+
objectKind: kind,
|
|
421
|
+
targetFqn,
|
|
422
|
+
additionalContext: opts.context ? String(opts.context) : void 0
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
completeFn: async (prompt) => {
|
|
426
|
+
const r = await ai2.complete([{ role: "user", content: prompt }], {
|
|
427
|
+
feature: "object-sketch",
|
|
428
|
+
maxSpendUsd: Number(opts.aiMaxSpend ?? "0") || 0
|
|
429
|
+
});
|
|
430
|
+
return r.text;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
const output = String(opts.format).toLowerCase() === "json" ? JSON.stringify({ ...result, rawModelText: void 0 }, null, 2) : result.generatedSql;
|
|
435
|
+
if (opts.out) {
|
|
436
|
+
await fs2.writeFile(String(opts.out), output, "utf8");
|
|
437
|
+
console.log(`Wrote ${String(opts.out)} (${output.length} bytes)`);
|
|
438
|
+
} else {
|
|
439
|
+
console.log(output);
|
|
440
|
+
}
|
|
441
|
+
if (result.assumptions.length > 0) {
|
|
442
|
+
console.error("");
|
|
443
|
+
console.error("Model assumptions:");
|
|
444
|
+
for (const a of result.assumptions) console.error(` - ${a}`);
|
|
445
|
+
}
|
|
446
|
+
if (result.parseFailed) {
|
|
447
|
+
console.warn("Model output could not be parsed \u2014 see the raw text via --format json.");
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
return cmd;
|
|
451
|
+
}
|
|
452
|
+
function splitFqn(fqn) {
|
|
453
|
+
const parts = fqn.split(".");
|
|
454
|
+
if (parts.length === 1) return { name: parts[0] };
|
|
455
|
+
if (parts.length === 2) return { schema: parts[0], name: parts[1] };
|
|
456
|
+
if (parts.length === 3) return { catalog: parts[0], schema: parts[1], name: parts[2] };
|
|
457
|
+
throw new Error(`Invalid --target "${fqn}": expected 1, 2, or 3 dot-separated parts.`);
|
|
458
|
+
}
|
|
459
|
+
async function readStdin() {
|
|
460
|
+
const chunks = [];
|
|
461
|
+
for await (const chunk of process.stdin) {
|
|
462
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
463
|
+
}
|
|
464
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/commands/compare-profiles.ts
|
|
468
|
+
import { promises as fs3 } from "fs";
|
|
469
|
+
import { Command as Command6 } from "commander";
|
|
470
|
+
import { catalog, compareProfiles, loadProject, pac, parseProjectModel } from "@ddt-tools/core";
|
|
471
|
+
function compareProfilesCommand() {
|
|
472
|
+
const cmd = new Command6("compare-profiles");
|
|
473
|
+
cmd.description("Manage saved compare profiles (.ddt/compare-profiles.json).");
|
|
474
|
+
cmd.command("list").description("List every saved profile.").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--json", "Emit JSON instead of a human table.").action(async (opts) => {
|
|
475
|
+
const store = new compareProfiles.CompareProfilesStore({ root: String(opts.root) });
|
|
476
|
+
const all = await store.list();
|
|
477
|
+
if (opts.json) {
|
|
478
|
+
console.log(JSON.stringify(all, null, 2));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (all.length === 0) {
|
|
482
|
+
console.log("(no compare profiles saved yet)");
|
|
483
|
+
console.log(` store: ${store.path}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
console.log(`${all.length} compare profile(s):`);
|
|
487
|
+
for (const p of all) {
|
|
488
|
+
console.log(` ${p.id.padEnd(24)} ${p.name}`);
|
|
489
|
+
console.log(` source: ${p.source.kind}=${p.source.reference}`);
|
|
490
|
+
console.log(` target: ${p.target.kind}=${p.target.reference}`);
|
|
491
|
+
console.log(` mappings: ${p.mappings.length} updated: ${p.updatedAt}`);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
cmd.command("show").description("Show one profile by id.").argument("<id>", "Profile id (from `compare-profiles list`).").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--json", "Emit JSON instead of a human table.").action(async (id, opts) => {
|
|
495
|
+
const store = new compareProfiles.CompareProfilesStore({ root: String(opts.root) });
|
|
496
|
+
const p = await store.get(String(id));
|
|
497
|
+
if (!p) {
|
|
498
|
+
console.error(
|
|
499
|
+
`No profile with id "${id}". Run \`ddt compare-profiles list\` to enumerate.`
|
|
500
|
+
);
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (opts.json) {
|
|
505
|
+
console.log(JSON.stringify(p, null, 2));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
console.log(`Profile: ${p.name} (${p.id})`);
|
|
509
|
+
if (p.description) console.log(` ${p.description}`);
|
|
510
|
+
console.log(
|
|
511
|
+
` source: ${p.source.kind}=${p.source.reference}${p.source.catalog ? ` cat=${p.source.catalog}` : ""}${p.source.schema ? ` schema=${p.source.schema}` : ""}`
|
|
512
|
+
);
|
|
513
|
+
console.log(
|
|
514
|
+
` target: ${p.target.kind}=${p.target.reference}${p.target.catalog ? ` cat=${p.target.catalog}` : ""}${p.target.schema ? ` schema=${p.target.schema}` : ""}`
|
|
515
|
+
);
|
|
516
|
+
console.log(` case-sensitive: ${p.caseSensitive ? "yes" : "no"}`);
|
|
517
|
+
console.log(` rewrite-inside-strings: ${p.rewriteInsideStrings ? "yes" : "no"}`);
|
|
518
|
+
console.log(` updatedAt: ${p.updatedAt}`);
|
|
519
|
+
if (p.mappings.length === 0) {
|
|
520
|
+
console.log(" mappings: (none \u2014 identity)");
|
|
521
|
+
} else {
|
|
522
|
+
console.log(` mappings (${p.mappings.length}):`);
|
|
523
|
+
for (const m of p.mappings) console.log(` ${m.source} => ${m.target}`);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
cmd.command("save").description("Upsert a profile from a JSON file or stdin.").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--json-file <path>", 'Path to a JSON file containing one profile. Use "-" for stdin.').action(async (opts) => {
|
|
527
|
+
if (!opts.jsonFile) {
|
|
528
|
+
throw new Error('--json-file is required (use a path or "-" for stdin).');
|
|
529
|
+
}
|
|
530
|
+
const raw = String(opts.jsonFile) === "-" ? await readStdin2() : await fs3.readFile(String(opts.jsonFile), "utf8");
|
|
531
|
+
const parsed = JSON.parse(raw);
|
|
532
|
+
if (!parsed?.id || !parsed?.name || !parsed?.source || !parsed?.target) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
"Profile JSON must contain at minimum: id, name, source, target. mappings defaults to []."
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const store = new compareProfiles.CompareProfilesStore({ root: String(opts.root) });
|
|
538
|
+
const stamped = await store.upsert({ ...parsed, mappings: parsed.mappings ?? [] });
|
|
539
|
+
console.log(`Saved profile "${stamped.name}" (${stamped.id})`);
|
|
540
|
+
console.log(` store: ${store.path}`);
|
|
541
|
+
});
|
|
542
|
+
cmd.command("remove").description("Delete one profile by id.").argument("<id>", "Profile id (from `compare-profiles list`).").option("--root <path>", "Project root. Default cwd.", process.cwd()).action(async (id, opts) => {
|
|
543
|
+
const store = new compareProfiles.CompareProfilesStore({ root: String(opts.root) });
|
|
544
|
+
const removed = await store.remove(String(id));
|
|
545
|
+
if (removed) {
|
|
546
|
+
console.log(`Removed profile "${id}".`);
|
|
547
|
+
} else {
|
|
548
|
+
console.warn(`No profile with id "${id}" \u2014 nothing to remove.`);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
cmd.command("preview").description(
|
|
552
|
+
"Preview which FQNs match between the profile's source and target. Local-only \u2014 no workspace round-trip."
|
|
553
|
+
).argument("<id>", "Profile id (from `compare-profiles list`).").option("--root <path>", "Project root. Default cwd.", process.cwd()).option(
|
|
554
|
+
"--examples <n>",
|
|
555
|
+
"Max example FQNs to show per bucket in human output. Default 5.",
|
|
556
|
+
(v) => parseInt(v, 10),
|
|
557
|
+
5
|
|
558
|
+
).option("--json", "Emit the full PreviewSummary as JSON.").action(async (id, opts) => {
|
|
559
|
+
const store = new compareProfiles.CompareProfilesStore({ root: String(opts.root) });
|
|
560
|
+
const profile = await store.get(String(id));
|
|
561
|
+
if (!profile) {
|
|
562
|
+
console.error(
|
|
563
|
+
`No profile with id "${id}". Run \`ddt compare-profiles list\` to enumerate.`
|
|
564
|
+
);
|
|
565
|
+
process.exitCode = 1;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const root = String(opts.root);
|
|
569
|
+
const source = await resolveEndpointFqns(profile.source, root);
|
|
570
|
+
const target = await resolveEndpointFqns(profile.target, root);
|
|
571
|
+
const summary = compareProfiles.previewMatch({
|
|
572
|
+
source: source.fqns,
|
|
573
|
+
target: target.fqns,
|
|
574
|
+
mappings: profile.mappings,
|
|
575
|
+
...profile.caseSensitive ? { caseSensitive: true } : {}
|
|
576
|
+
});
|
|
577
|
+
if (opts.json) {
|
|
578
|
+
console.log(
|
|
579
|
+
JSON.stringify(
|
|
580
|
+
{
|
|
581
|
+
profile: { id: profile.id, name: profile.name },
|
|
582
|
+
source: {
|
|
583
|
+
kind: profile.source.kind,
|
|
584
|
+
reference: profile.source.reference,
|
|
585
|
+
count: source.fqns.length
|
|
586
|
+
},
|
|
587
|
+
target: {
|
|
588
|
+
kind: profile.target.kind,
|
|
589
|
+
reference: profile.target.reference,
|
|
590
|
+
count: target.fqns.length
|
|
591
|
+
},
|
|
592
|
+
summary
|
|
593
|
+
},
|
|
594
|
+
null,
|
|
595
|
+
2
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
console.log(`Preview: ${profile.name} (${profile.id})`);
|
|
601
|
+
console.log(
|
|
602
|
+
` source (${profile.source.kind}=${profile.source.reference}): ${source.fqns.length} object(s)${source.note ? ` \u2014 ${source.note}` : ""}`
|
|
603
|
+
);
|
|
604
|
+
console.log(
|
|
605
|
+
` target (${profile.target.kind}=${profile.target.reference}): ${target.fqns.length} object(s)${target.note ? ` \u2014 ${target.note}` : ""}`
|
|
606
|
+
);
|
|
607
|
+
console.log("");
|
|
608
|
+
console.log(` matched: ${summary.matchedCount}`);
|
|
609
|
+
console.log(` source-only: ${summary.sourceOnlyCount}`);
|
|
610
|
+
console.log(` target-only: ${summary.targetOnlyCount}`);
|
|
611
|
+
const exN = Math.max(0, Number(opts.examples));
|
|
612
|
+
printBucket("matched", summary.matched, exN);
|
|
613
|
+
printBucket("source-only", summary.sourceOnly, exN);
|
|
614
|
+
printBucket("target-only", summary.targetOnly, exN);
|
|
615
|
+
if (summary.matchedCount === 0 && (summary.sourceOnlyCount > 0 || summary.targetOnlyCount > 0)) {
|
|
616
|
+
console.warn("No FQNs matched \u2014 check the profile's scope and mapping rules.");
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
return cmd;
|
|
620
|
+
}
|
|
621
|
+
async function resolveEndpointFqns(endpoint, root) {
|
|
622
|
+
if (endpoint.kind === "connection") {
|
|
623
|
+
const cache = new catalog.CatalogCache({ root, connection: endpoint.reference });
|
|
624
|
+
const snapshot = await cache.get();
|
|
625
|
+
if (snapshot.catalogs.length === 0) {
|
|
626
|
+
return {
|
|
627
|
+
fqns: [],
|
|
628
|
+
note: `empty catalog cache at ${cache.path} \u2014 run \`ddt catalog refresh --connection ${endpoint.reference}\` first`
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return { fqns: fqnsFromSnapshot(snapshot, endpoint.catalog, endpoint.schema) };
|
|
632
|
+
}
|
|
633
|
+
if (endpoint.kind === "pac") {
|
|
634
|
+
const contents = await pac.readPac(endpoint.reference);
|
|
635
|
+
return { fqns: fqnsFromObjects(contents.model, endpoint.catalog, endpoint.schema) };
|
|
636
|
+
}
|
|
637
|
+
const loaded = await loadProject(endpoint.reference);
|
|
638
|
+
const model = await parseProjectModel(loaded);
|
|
639
|
+
return { fqns: fqnsFromObjects(model, endpoint.catalog, endpoint.schema) };
|
|
640
|
+
}
|
|
641
|
+
function fqnsFromSnapshot(snapshot, catalogScope, schemaScope) {
|
|
642
|
+
const sameId = (a, b) => !!a && !!b && a.toUpperCase() === b.toUpperCase();
|
|
643
|
+
const out = [];
|
|
644
|
+
for (const cat of snapshot.catalogs) {
|
|
645
|
+
if (catalogScope && !sameId(cat.catalog, catalogScope)) continue;
|
|
646
|
+
for (const sc of cat.schemas) {
|
|
647
|
+
if (schemaScope && !sameId(sc.schema, schemaScope)) continue;
|
|
648
|
+
for (const obj of sc.objects) {
|
|
649
|
+
out.push({ database: obj.catalog, schema: obj.schema, name: obj.name });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
function fqnsFromObjects(model, catalogScope, schemaScope) {
|
|
656
|
+
const sameId = (a, b) => !!a && !!b && a.toUpperCase() === b.toUpperCase();
|
|
657
|
+
const out = [];
|
|
658
|
+
for (const obj of model) {
|
|
659
|
+
if (catalogScope && !sameId(obj.fqn.database, catalogScope)) continue;
|
|
660
|
+
if (schemaScope && !sameId(obj.fqn.schema, schemaScope)) continue;
|
|
661
|
+
out.push(obj.fqn);
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
function printBucket(label, items, exampleN) {
|
|
666
|
+
if (items.length === 0) return;
|
|
667
|
+
console.log("");
|
|
668
|
+
console.log(
|
|
669
|
+
` ${label} examples (showing ${Math.min(exampleN, items.length)} of ${items.length}):`
|
|
670
|
+
);
|
|
671
|
+
for (const fqn of items.slice(0, exampleN)) console.log(` ${fqn}`);
|
|
672
|
+
}
|
|
673
|
+
async function readStdin2() {
|
|
674
|
+
const chunks = [];
|
|
675
|
+
for await (const chunk of process.stdin) {
|
|
676
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
677
|
+
}
|
|
678
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/commands/explorer.ts
|
|
682
|
+
import { Command as Command7 } from "commander";
|
|
683
|
+
import { catalog as catalog2, objectExplorer } from "@ddt-tools/core";
|
|
684
|
+
function explorerCommand() {
|
|
685
|
+
const cmd = new Command7("explorer");
|
|
686
|
+
cmd.description(
|
|
687
|
+
"ASCII tree dump of the cached catalog for a connection. Run `ddt catalog refresh` first to populate."
|
|
688
|
+
).requiredOption("--connection <name>", "Connection profile name (the cache subfolder).").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--filter <query>", "Typeahead substring filter (case-insensitive).").option(
|
|
689
|
+
"--depth <n>",
|
|
690
|
+
"Truncate tree at depth N (0 = root only, 1 = +catalogs, 2 = +schemas, 3 = +object-groups, 4 = +objects). Default unlimited.",
|
|
691
|
+
(v) => parseInt(v, 10)
|
|
692
|
+
).option("--json", "Emit tree as JSON instead of ASCII.").action(async (opts) => {
|
|
693
|
+
const cache = new catalog2.CatalogCache({
|
|
694
|
+
root: String(opts.root),
|
|
695
|
+
connection: String(opts.connection)
|
|
696
|
+
});
|
|
697
|
+
const snapshot = await cache.get();
|
|
698
|
+
let tree = objectExplorer.treeForSnapshot(snapshot);
|
|
699
|
+
if (opts.filter) tree = objectExplorer.filterTree(tree, String(opts.filter));
|
|
700
|
+
if (typeof opts.depth === "number") tree = objectExplorer.truncateTree(tree, opts.depth);
|
|
701
|
+
if (snapshot.catalogs.length === 0) {
|
|
702
|
+
console.warn(`Catalog cache is empty for connection "${opts.connection}".`);
|
|
703
|
+
console.warn(` Cache file: ${cache.path}`);
|
|
704
|
+
console.warn(` Run \`ddt catalog refresh --connection ${opts.connection}\` to populate.`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (opts.json) {
|
|
708
|
+
console.log(JSON.stringify(tree, null, 2));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
renderAsciiTree(tree);
|
|
712
|
+
});
|
|
713
|
+
return cmd;
|
|
714
|
+
}
|
|
715
|
+
function renderAsciiTree(root) {
|
|
716
|
+
const desc = root.description ? ` (${root.description})` : "";
|
|
717
|
+
console.log(`${root.label}${desc}`);
|
|
718
|
+
const children = root.children ?? [];
|
|
719
|
+
for (let i = 0; i < children.length; i++) {
|
|
720
|
+
renderChild(children[i], "", i === children.length - 1);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function renderChild(node, prefix, isLast) {
|
|
724
|
+
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
725
|
+
const desc = node.description ? ` (${node.description})` : "";
|
|
726
|
+
console.log(`${prefix}${connector}${node.label}${desc}`);
|
|
727
|
+
const children = node.children ?? [];
|
|
728
|
+
const childPrefix = prefix + (isLast ? " " : "\u2502 ");
|
|
729
|
+
for (let i = 0; i < children.length; i++) {
|
|
730
|
+
renderChild(children[i], childPrefix, i === children.length - 1);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/commands/catalog.ts
|
|
735
|
+
import { Command as Command8 } from "commander";
|
|
736
|
+
import { catalog as catalog3, createConnection as createConnection2, getProfile as getProfile2 } from "@ddt-tools/core";
|
|
737
|
+
var BUILTIN_CATALOGS = /* @__PURE__ */ new Set(["system", "samples", "__databricks_internal"]);
|
|
738
|
+
function catalogCommand() {
|
|
739
|
+
const cmd = new Command8("catalog");
|
|
740
|
+
cmd.description(
|
|
741
|
+
"Manage the per-connection catalog cache used by the Object Explorer + EE2 intellisense."
|
|
742
|
+
);
|
|
743
|
+
cmd.command("refresh").description("Open the connection, scan every (non-builtin) catalog, write the catalog cache.").requiredOption("-c, --connection <name>", "Connection profile name.").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--catalogs <csv>", "Comma-separated list. Default: every non-builtin catalog.").option("--include-builtins", "Include system / samples / __databricks_internal.", false).option(
|
|
744
|
+
"--concurrency <n>",
|
|
745
|
+
"Bounded-concurrency cap for the bulk-scan pool. Default 10.",
|
|
746
|
+
"10"
|
|
747
|
+
).option("--no-fingerprint-skip", "Force a full scan even when fingerprints are unchanged.").action(async (opts) => {
|
|
748
|
+
const profile = await getProfile2(String(opts.connection));
|
|
749
|
+
const conn = createConnection2(profile);
|
|
750
|
+
const cache = new catalog3.CatalogCache({
|
|
751
|
+
root: String(opts.root),
|
|
752
|
+
connection: profile.name
|
|
753
|
+
});
|
|
754
|
+
console.log(`Connecting to ${profile.auth.host}\u2026`);
|
|
755
|
+
await conn.connect();
|
|
756
|
+
try {
|
|
757
|
+
const cats = await resolveCatalogs(conn, opts);
|
|
758
|
+
if (cats.length === 0) {
|
|
759
|
+
console.warn("No catalogs to scan. Pass --catalogs or --include-builtins.");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
console.log(
|
|
763
|
+
`Scanning ${cats.length} catalog(s) with concurrency ${Number(opts.concurrency)}.`
|
|
764
|
+
);
|
|
765
|
+
const cached = await cache.get();
|
|
766
|
+
const cachedByCat = new Map(cached.catalogs.map((c) => [c.catalog, c]));
|
|
767
|
+
const concurrency = Math.max(1, Number(opts.concurrency) || 10);
|
|
768
|
+
const fingerprintSkip = opts.fingerprintSkip !== false;
|
|
769
|
+
const { results, errors } = await catalog3.mapPool(
|
|
770
|
+
cats,
|
|
771
|
+
async (cat) => {
|
|
772
|
+
let freshFingerprint = null;
|
|
773
|
+
try {
|
|
774
|
+
const fpRes = await conn.query(
|
|
775
|
+
catalog3.fingerprintSqlForCatalog(cat)
|
|
776
|
+
);
|
|
777
|
+
freshFingerprint = catalog3.parseFingerprintRow(fpRes.rows);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
console.warn(
|
|
780
|
+
`fingerprint(${cat}) failed; falling through to full scan: ${err.message}`
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const cachedCat = cachedByCat.get(cat);
|
|
784
|
+
if (fingerprintSkip && cachedCat && cachedCat.fingerprint != null && freshFingerprint != null && catalog3.isFresh(cachedCat.fingerprint, freshFingerprint)) {
|
|
785
|
+
console.log(` ${cat}: fresh; reusing cache`);
|
|
786
|
+
return {
|
|
787
|
+
catalog: cat,
|
|
788
|
+
fingerprint: cachedCat.fingerprint,
|
|
789
|
+
schemas: cachedCat.schemas
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const scanRes = await conn.query(catalog3.bulkScanSqlForCatalog(cat));
|
|
793
|
+
const schemas = catalog3.parseBulkScanRows(cat, scanRes.rows);
|
|
794
|
+
const fingerprint = freshFingerprint ?? null;
|
|
795
|
+
console.log(
|
|
796
|
+
` ${cat}: ${schemas.length} schemas / ${schemas.reduce((sum, s) => sum + s.objects.length, 0)} objects`
|
|
797
|
+
);
|
|
798
|
+
return { catalog: cat, fingerprint, schemas };
|
|
799
|
+
},
|
|
800
|
+
{ concurrency }
|
|
801
|
+
);
|
|
802
|
+
for (const e of errors) {
|
|
803
|
+
console.warn(`scan failed for "${cats[e.index]}": ${e.error.message}`);
|
|
804
|
+
}
|
|
805
|
+
const snapshot = catalog3.refreshFingerprints({
|
|
806
|
+
version: catalog3.CATALOG_SNAPSHOT_VERSION,
|
|
807
|
+
connection: profile.name,
|
|
808
|
+
snapshotAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
809
|
+
fingerprint: null,
|
|
810
|
+
catalogs: results.filter((r) => !!r)
|
|
811
|
+
});
|
|
812
|
+
await cache.set(snapshot);
|
|
813
|
+
console.log(`Wrote ${snapshot.catalogs.length} catalogs to ${cache.path}`);
|
|
814
|
+
} finally {
|
|
815
|
+
await conn.disconnect();
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
cmd.command("show").description("Pretty-print or JSON-dump the cached snapshot for a connection.").requiredOption("-c, --connection <name>", "Connection profile name.").option("--root <path>", "Project root. Default cwd.", process.cwd()).option("--json", "Emit JSON instead of summary.").action(async (opts) => {
|
|
819
|
+
const cache = new catalog3.CatalogCache({
|
|
820
|
+
root: String(opts.root),
|
|
821
|
+
connection: String(opts.connection)
|
|
822
|
+
});
|
|
823
|
+
const snapshot = await cache.get();
|
|
824
|
+
if (opts.json) {
|
|
825
|
+
console.log(JSON.stringify(snapshot, null, 2));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (snapshot.catalogs.length === 0) {
|
|
829
|
+
console.warn(`Catalog cache is empty for "${opts.connection}".`);
|
|
830
|
+
console.warn(` Cache file: ${cache.path}`);
|
|
831
|
+
console.warn(` Run \`ddt catalog refresh --connection ${opts.connection}\` to populate.`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
console.log(`Snapshot for "${snapshot.connection}" \u2014 ${snapshot.snapshotAt}`);
|
|
835
|
+
console.log(
|
|
836
|
+
` Top-level fingerprint: ${snapshot.fingerprint != null ? new Date(snapshot.fingerprint).toISOString() : "(none)"}`
|
|
837
|
+
);
|
|
838
|
+
for (const cat of snapshot.catalogs) {
|
|
839
|
+
const objCount = cat.schemas.reduce((sum, s) => sum + s.objects.length, 0);
|
|
840
|
+
console.log(
|
|
841
|
+
` ${cat.catalog.padEnd(32)} ${cat.schemas.length} schemas / ${objCount} objects`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
cmd.command("clear").description("Delete the cached snapshot for a connection.").requiredOption("-c, --connection <name>", "Connection profile name.").option("--root <path>", "Project root. Default cwd.", process.cwd()).action(async (opts) => {
|
|
846
|
+
const cache = new catalog3.CatalogCache({
|
|
847
|
+
root: String(opts.root),
|
|
848
|
+
connection: String(opts.connection)
|
|
849
|
+
});
|
|
850
|
+
await cache.clear();
|
|
851
|
+
console.log(`Cleared cache for "${opts.connection}".`);
|
|
852
|
+
console.log(` was: ${cache.path}`);
|
|
853
|
+
});
|
|
854
|
+
return cmd;
|
|
855
|
+
}
|
|
856
|
+
async function resolveCatalogs(conn, opts) {
|
|
857
|
+
if (opts.catalogs) {
|
|
858
|
+
return String(opts.catalogs).split(",").map((s) => s.trim()).filter(Boolean);
|
|
859
|
+
}
|
|
860
|
+
const res = await conn.query("SHOW CATALOGS");
|
|
861
|
+
const names = res.rows.map((r) => String(r.catalog ?? r.catalog_name ?? r.CATALOG ?? r.CATALOG_NAME ?? "")).filter(Boolean);
|
|
862
|
+
if (opts.includeBuiltins) return names;
|
|
863
|
+
return names.filter((n) => !BUILTIN_CATALOGS.has(n.toLowerCase()));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/commands/exec.ts
|
|
867
|
+
import { Command as Command9 } from "commander";
|
|
868
|
+
import { promises as fs4 } from "fs";
|
|
869
|
+
import { getProfile as getProfile3, createConnection as createConnection3 } from "@ddt-tools/core/connection";
|
|
870
|
+
import { queryExecution } from "@ddt-tools/core";
|
|
871
|
+
var PROD_PATTERN = /\bprod(uction)?\b/i;
|
|
872
|
+
function isProductionProfile(name) {
|
|
873
|
+
return PROD_PATTERN.test(name);
|
|
874
|
+
}
|
|
875
|
+
async function runOnProfile(execFn, profile, sql, timeoutMs) {
|
|
876
|
+
const start = Date.now();
|
|
877
|
+
try {
|
|
878
|
+
const res = await execFn(profile, sql, timeoutMs);
|
|
879
|
+
return { ...res, profile, durationMs: Date.now() - start };
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return {
|
|
882
|
+
profile,
|
|
883
|
+
status: "error",
|
|
884
|
+
durationMs: Date.now() - start,
|
|
885
|
+
error: err instanceof Error ? err.message : String(err)
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function renderText(results) {
|
|
890
|
+
const lines = [];
|
|
891
|
+
for (const r of results) {
|
|
892
|
+
if (r.status === "success") {
|
|
893
|
+
const rows = r.rowsAffected !== void 0 ? ` (${r.rowsAffected} rows)` : "";
|
|
894
|
+
lines.push(` ${r.profile}: \u2713${rows} \u2014 ${(r.durationMs / 1e3).toFixed(1)}s`);
|
|
895
|
+
} else {
|
|
896
|
+
lines.push(` ${r.profile}: \u2717 ${r.error ?? "unknown error"}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const total = results.length;
|
|
900
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
901
|
+
lines.push(`
|
|
902
|
+
${succeeded}/${total} profiles succeeded.`);
|
|
903
|
+
return lines.join("\n");
|
|
904
|
+
}
|
|
905
|
+
async function defaultExecFn(profile, sql, timeoutMs) {
|
|
906
|
+
const profileObj = await getProfile3(profile);
|
|
907
|
+
const conn = createConnection3(profileObj);
|
|
908
|
+
await conn.connect();
|
|
909
|
+
try {
|
|
910
|
+
const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1e3));
|
|
911
|
+
const runner = {
|
|
912
|
+
executeStatement: async (statement) => {
|
|
913
|
+
const result2 = await conn.query(statement, { timeoutSeconds });
|
|
914
|
+
return {
|
|
915
|
+
rows: result2.rows,
|
|
916
|
+
durationMs: result2.durationMs
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
const result = await queryExecution.executeStatements(sql, runner, {
|
|
921
|
+
failFast: false,
|
|
922
|
+
splitOptions: { dialect: "databricks" },
|
|
923
|
+
toolName: "ddt"
|
|
924
|
+
});
|
|
925
|
+
const firstError = result.statements.find((s) => s.error);
|
|
926
|
+
if (firstError && firstError.error) {
|
|
927
|
+
throw new Error(firstError.error.message);
|
|
928
|
+
}
|
|
929
|
+
const rowsAffected = result.statements.reduce((acc, s) => acc + (s.rowCount ?? 0), 0);
|
|
930
|
+
return { status: "success", rowsAffected };
|
|
931
|
+
} finally {
|
|
932
|
+
await conn.disconnect().catch(() => {
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function execCommand(execFn = defaultExecFn) {
|
|
937
|
+
const cmd = new Command9("exec");
|
|
938
|
+
cmd.description("Run a SQL script on one or more connection profiles in parallel.").argument("<file>", "Path to the .sql file to execute.").requiredOption("--profiles <list>", "Comma-separated connection profile names.").option("--yes", "Confirm execution against production profiles without prompting.").option("--format <fmt>", "text | json (default text).", "text").option("--timeout <ms>", "Per-profile timeout in milliseconds.", "30000").action(async (file, opts) => {
|
|
939
|
+
const profiles = opts.profiles.split(",").map((p) => p.trim()).filter(Boolean);
|
|
940
|
+
if (profiles.length === 0) throw new Error("--profiles must list at least one profile.");
|
|
941
|
+
const prodProfiles = profiles.filter(isProductionProfile);
|
|
942
|
+
if (prodProfiles.length > 0 && !opts.yes) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
`Profile(s) look like production: ${prodProfiles.join(", ")}. Pass --yes to confirm.`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
let sql;
|
|
948
|
+
try {
|
|
949
|
+
sql = await fs4.readFile(file, "utf8");
|
|
950
|
+
} catch {
|
|
951
|
+
throw new Error(`Cannot read file: ${file}`);
|
|
952
|
+
}
|
|
953
|
+
if (!sql.trim()) throw new Error(`File is empty: ${file}`);
|
|
954
|
+
const timeoutMs = parseInt(String(opts.timeout ?? "30000"), 10);
|
|
955
|
+
const results = await Promise.all(
|
|
956
|
+
profiles.map((p) => runOnProfile(execFn, p, sql, timeoutMs))
|
|
957
|
+
);
|
|
958
|
+
const fmt = String(opts.format ?? "text").toLowerCase();
|
|
959
|
+
if (fmt === "json") {
|
|
960
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
961
|
+
process.stdout.write(
|
|
962
|
+
JSON.stringify(
|
|
963
|
+
{
|
|
964
|
+
results,
|
|
965
|
+
summary: {
|
|
966
|
+
total: results.length,
|
|
967
|
+
succeeded: results.length - failed,
|
|
968
|
+
failed
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
null,
|
|
972
|
+
2
|
|
973
|
+
) + "\n"
|
|
974
|
+
);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (fmt !== "text") throw new Error(`Unknown --format: ${opts.format}. Use text | json.`);
|
|
978
|
+
process.stdout.write(renderText(results) + "\n");
|
|
979
|
+
});
|
|
980
|
+
return cmd;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/commands/search.ts
|
|
984
|
+
import { Command as Command10 } from "commander";
|
|
985
|
+
import { promises as fs5 } from "fs";
|
|
986
|
+
import os from "os";
|
|
987
|
+
import path from "path";
|
|
988
|
+
import { catalog as catalog4 } from "@ddt-tools/core";
|
|
989
|
+
function buildDriftReport(results) {
|
|
990
|
+
const allFqns = /* @__PURE__ */ new Set();
|
|
991
|
+
const byFqn = /* @__PURE__ */ new Map();
|
|
992
|
+
for (const r of results) {
|
|
993
|
+
if (r.error) continue;
|
|
994
|
+
for (const m of r.matches) {
|
|
995
|
+
allFqns.add(m.fqn);
|
|
996
|
+
if (!byFqn.has(m.fqn)) byFqn.set(m.fqn, /* @__PURE__ */ new Set());
|
|
997
|
+
byFqn.get(m.fqn).add(r.profile);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const successProfiles = results.filter((r) => !r.error).map((r) => r.profile);
|
|
1001
|
+
const driftLines = [];
|
|
1002
|
+
for (const fqn of allFqns) {
|
|
1003
|
+
const present = byFqn.get(fqn);
|
|
1004
|
+
if (present.size < successProfiles.length) {
|
|
1005
|
+
const absent = successProfiles.filter((p) => !present.has(p));
|
|
1006
|
+
driftLines.push(
|
|
1007
|
+
` DRIFT ${fqn} [present: ${[...present].join(", ")} absent: ${absent.join(", ")}]`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return driftLines.join("\n");
|
|
1012
|
+
}
|
|
1013
|
+
function renderText2(results, showDrift) {
|
|
1014
|
+
const lines = [];
|
|
1015
|
+
for (const r of results) {
|
|
1016
|
+
if (r.error) {
|
|
1017
|
+
lines.push(`[${r.profile}] ERROR: ${r.error}`);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (r.matches.length === 0) {
|
|
1021
|
+
lines.push(`[${r.profile}] no matches`);
|
|
1022
|
+
} else {
|
|
1023
|
+
for (const m of r.matches) {
|
|
1024
|
+
lines.push(`[${r.profile}] ${m.fqn} (${m.objectType})`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (showDrift) {
|
|
1029
|
+
const drift = buildDriftReport(results);
|
|
1030
|
+
if (drift) {
|
|
1031
|
+
lines.push("");
|
|
1032
|
+
lines.push("--- Drift detected ---");
|
|
1033
|
+
lines.push(drift);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return lines.join("\n");
|
|
1037
|
+
}
|
|
1038
|
+
async function loadProfileNames(profilesOpt, profilesDir) {
|
|
1039
|
+
if (profilesOpt.toLowerCase() !== "all") {
|
|
1040
|
+
return profilesOpt.split(",").map((p) => p.trim()).filter(Boolean);
|
|
1041
|
+
}
|
|
1042
|
+
try {
|
|
1043
|
+
const entries = await fs5.readdir(profilesDir);
|
|
1044
|
+
return entries.filter((e) => e.endsWith(".json") || e.endsWith(".toml")).map((e) => path.basename(e, path.extname(e)));
|
|
1045
|
+
} catch {
|
|
1046
|
+
return [];
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async function defaultSearchFn(profile, pattern, opts) {
|
|
1050
|
+
const root = opts.root ?? process.cwd();
|
|
1051
|
+
const cache = new catalog4.CatalogCache({ root, connection: profile });
|
|
1052
|
+
const snapshot = await cache.get();
|
|
1053
|
+
if (snapshot.catalogs.length === 0) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
`No catalog cache for profile "${profile}" at ${cache.path}. Run \`ddt catalog refresh --connection ${profile} --root ${root}\` or \`ddt extract --connection ${profile} --output ${root} --write-catalog-cache\` first.`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const needle = pattern.toLowerCase();
|
|
1059
|
+
const typeFilter = opts.objectType ? opts.objectType.toUpperCase() : void 0;
|
|
1060
|
+
const matches = [];
|
|
1061
|
+
for (const cat of snapshot.catalogs) {
|
|
1062
|
+
for (const schema of cat.schemas) {
|
|
1063
|
+
for (const obj of schema.objects) {
|
|
1064
|
+
if (typeFilter && obj.objectType !== typeFilter) continue;
|
|
1065
|
+
const name = obj.name.toLowerCase();
|
|
1066
|
+
const hit = opts.exact ? name === needle : name.includes(needle);
|
|
1067
|
+
if (hit) {
|
|
1068
|
+
matches.push({
|
|
1069
|
+
profile,
|
|
1070
|
+
fqn: `${obj.catalog}.${obj.schema}.${obj.name}`,
|
|
1071
|
+
objectType: obj.objectType
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return matches;
|
|
1078
|
+
}
|
|
1079
|
+
function searchCommand(searchFn = defaultSearchFn) {
|
|
1080
|
+
const cmd = new Command10("search");
|
|
1081
|
+
cmd.description("Find objects matching a name pattern across one or more connection profiles.").argument("<pattern>", "Name pattern to search for (case-insensitive substring).").requiredOption(
|
|
1082
|
+
"--profiles <list|all>",
|
|
1083
|
+
'Comma-separated profile names, or "all" to scan every configured profile.'
|
|
1084
|
+
).option("--exact", "Exact name match instead of substring.", false).option("--type <objectType>", "Filter results to a specific object type (e.g. TABLE, VIEW).").option("--format <fmt>", "text | json (default text).", "text").option(
|
|
1085
|
+
"--profiles-dir <dir>",
|
|
1086
|
+
"Directory to scan when --profiles all is used.",
|
|
1087
|
+
path.join(os.homedir(), ".ddt", "connections")
|
|
1088
|
+
).option(
|
|
1089
|
+
"--root <dir>",
|
|
1090
|
+
"Project / cache root containing the .ddt/cache directory. Default: cwd.",
|
|
1091
|
+
process.cwd()
|
|
1092
|
+
).option("--no-drift", "Suppress the drift section in text output.").action(async (pattern, opts) => {
|
|
1093
|
+
const profiles = await loadProfileNames(opts.profiles, opts.profilesDir);
|
|
1094
|
+
if (profiles.length === 0) {
|
|
1095
|
+
throw new Error(
|
|
1096
|
+
opts.profiles.toLowerCase() === "all" ? `No profiles found in ${opts.profilesDir}. Run \`ddt connection add\` first.` : "--profiles must list at least one profile."
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
const results = await Promise.all(
|
|
1100
|
+
profiles.map(async (p) => {
|
|
1101
|
+
try {
|
|
1102
|
+
const matches = await searchFn(p, pattern, {
|
|
1103
|
+
exact: opts.exact,
|
|
1104
|
+
objectType: opts.type,
|
|
1105
|
+
root: opts.root
|
|
1106
|
+
});
|
|
1107
|
+
return { profile: p, matches };
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return {
|
|
1110
|
+
profile: p,
|
|
1111
|
+
matches: [],
|
|
1112
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
})
|
|
1116
|
+
);
|
|
1117
|
+
const fmt = String(opts.format ?? "text").toLowerCase();
|
|
1118
|
+
if (fmt === "json") {
|
|
1119
|
+
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (fmt !== "text") throw new Error(`Unknown --format: ${opts.format}. Use text | json.`);
|
|
1123
|
+
const text = renderText2(results, opts.drift !== false);
|
|
1124
|
+
process.stdout.write(text ? text + "\n" : "No results.\n");
|
|
1125
|
+
});
|
|
1126
|
+
return cmd;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/commands/query-log.ts
|
|
1130
|
+
import { Command as Command11 } from "commander";
|
|
1131
|
+
import { promises as fs6 } from "fs";
|
|
1132
|
+
import os2 from "os";
|
|
1133
|
+
import path2 from "path";
|
|
1134
|
+
import { queryLog } from "@ddt-tools/core";
|
|
1135
|
+
var DEFAULT_STORE = path2.join(os2.homedir(), ".ddt", "query-log.json");
|
|
1136
|
+
async function loadStore(storePath) {
|
|
1137
|
+
try {
|
|
1138
|
+
const raw = await fs6.readFile(storePath, "utf8");
|
|
1139
|
+
return queryLog.QueryLogStore.deserialize(JSON.parse(raw));
|
|
1140
|
+
} catch {
|
|
1141
|
+
return new queryLog.QueryLogStore();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function saveStore(storePath, store) {
|
|
1145
|
+
await fs6.mkdir(path2.dirname(storePath), { recursive: true });
|
|
1146
|
+
await fs6.writeFile(storePath, JSON.stringify(store.serialize(), null, 2), "utf8");
|
|
1147
|
+
}
|
|
1148
|
+
function queryLogCommand() {
|
|
1149
|
+
const cmd = new Command11("query-log");
|
|
1150
|
+
cmd.description("Browse and manage the local query log from the EE3 query window (AUTH.5).");
|
|
1151
|
+
cmd.command("list").description("List recent query log entries (newest first).").option("--profile <p>", "Filter to a specific connection profile.").option("--limit <n>", "Maximum entries to show (default 100).", "100").option("--format <fmt>", "text | json (default text).", "text").option("--store <path>", "Path to the query log JSON file.", DEFAULT_STORE).action(async (opts) => {
|
|
1152
|
+
const store = await loadStore(opts.store);
|
|
1153
|
+
const entries = store.listEntries({
|
|
1154
|
+
profile: opts.profile,
|
|
1155
|
+
limit: parseInt(String(opts.limit ?? "100"), 10)
|
|
1156
|
+
});
|
|
1157
|
+
const fmt = String(opts.format ?? "text").toLowerCase();
|
|
1158
|
+
if (fmt === "json") {
|
|
1159
|
+
process.stdout.write(JSON.stringify(entries, null, 2) + "\n");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (entries.length === 0) {
|
|
1163
|
+
process.stdout.write("No query log entries.\n");
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
for (const e of entries) {
|
|
1167
|
+
const when = e.executedAt.slice(0, 19).replace("T", " ");
|
|
1168
|
+
const dur = e.durationMs !== void 0 ? ` ${e.durationMs}ms` : "";
|
|
1169
|
+
const rows = e.rowCount !== void 0 ? ` ${e.rowCount} rows` : "";
|
|
1170
|
+
const status = e.status === "error" ? " \u2717" : " \u2713";
|
|
1171
|
+
const sql = e.sql.replace(/\s+/g, " ").slice(0, 80);
|
|
1172
|
+
process.stdout.write(`[${e.id}] ${when} [${e.profile}]${status}${dur}${rows} ${sql}
|
|
1173
|
+
`);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
cmd.command("show <id>").description("Show a single query log entry by id.").option("--format <fmt>", "text | json | sql (default text).", "text").option("--store <path>", "Path to the query log JSON file.", DEFAULT_STORE).action(async (id, opts) => {
|
|
1177
|
+
const store = await loadStore(opts.store);
|
|
1178
|
+
const entry = store.getEntry(id);
|
|
1179
|
+
if (!entry) throw new Error(`No entry with id '${id}'.`);
|
|
1180
|
+
const fmt = String(opts.format ?? "text").toLowerCase();
|
|
1181
|
+
if (fmt === "sql") {
|
|
1182
|
+
process.stdout.write(entry.sql + "\n");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (fmt === "json") {
|
|
1186
|
+
process.stdout.write(JSON.stringify(entry, null, 2) + "\n");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
process.stdout.write(`id: ${entry.id}
|
|
1190
|
+
`);
|
|
1191
|
+
process.stdout.write(`profile: ${entry.profile}
|
|
1192
|
+
`);
|
|
1193
|
+
process.stdout.write(`executed: ${entry.executedAt}
|
|
1194
|
+
`);
|
|
1195
|
+
process.stdout.write(`status: ${entry.status}
|
|
1196
|
+
`);
|
|
1197
|
+
if (entry.durationMs !== void 0) process.stdout.write(`duration: ${entry.durationMs}ms
|
|
1198
|
+
`);
|
|
1199
|
+
if (entry.rowCount !== void 0) process.stdout.write(`rows: ${entry.rowCount}
|
|
1200
|
+
`);
|
|
1201
|
+
if (entry.error) process.stdout.write(`error: ${entry.error}
|
|
1202
|
+
`);
|
|
1203
|
+
process.stdout.write(`
|
|
1204
|
+
${entry.sql}
|
|
1205
|
+
`);
|
|
1206
|
+
});
|
|
1207
|
+
cmd.command("add <sql>").description("Manually append a query to the log (used by EE3 and for testing).").requiredOption("--profile <p>", "Connection profile the query was run against.").option("--status <s>", "success | error (default success).", "success").option("--store <path>", "Path to the query log JSON file.", DEFAULT_STORE).action(async (sql, opts) => {
|
|
1208
|
+
if (opts.status !== "success" && opts.status !== "error") {
|
|
1209
|
+
throw new Error(`--status must be success or error, got: ${opts.status}`);
|
|
1210
|
+
}
|
|
1211
|
+
const store = await loadStore(opts.store);
|
|
1212
|
+
const entry = store.appendEntry({
|
|
1213
|
+
profile: opts.profile,
|
|
1214
|
+
sql,
|
|
1215
|
+
executedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1216
|
+
status: opts.status
|
|
1217
|
+
});
|
|
1218
|
+
await saveStore(opts.store, store);
|
|
1219
|
+
process.stdout.write(`Added entry ${entry.id}.
|
|
1220
|
+
`);
|
|
1221
|
+
});
|
|
1222
|
+
cmd.command("clear").description("Remove query log entries.").option("--profile <p>", "Remove only entries for this profile (omit for all).").option("--yes", "Skip confirmation prompt.").option("--store <path>", "Path to the query log JSON file.", DEFAULT_STORE).action(async (opts) => {
|
|
1223
|
+
if (!opts.yes) {
|
|
1224
|
+
const target = opts.profile ? `profile '${opts.profile}'` : "all profiles";
|
|
1225
|
+
throw new Error(`Pass --yes to confirm clearing the query log for ${target}.`);
|
|
1226
|
+
}
|
|
1227
|
+
const store = await loadStore(opts.store);
|
|
1228
|
+
const removed = store.clearEntries(opts.profile);
|
|
1229
|
+
await saveStore(opts.store, store);
|
|
1230
|
+
process.stdout.write(`Cleared ${removed} entries.
|
|
1231
|
+
`);
|
|
1232
|
+
});
|
|
1233
|
+
return cmd;
|
|
1234
|
+
}
|
|
1235
|
+
export {
|
|
1236
|
+
catalogCommand,
|
|
1237
|
+
compareProfilesCommand,
|
|
1238
|
+
connectionCommand,
|
|
1239
|
+
execCommand,
|
|
1240
|
+
explorerCommand,
|
|
1241
|
+
queryLogCommand,
|
|
1242
|
+
saferAlternativeCommand,
|
|
1243
|
+
safetyCommand,
|
|
1244
|
+
scaffoldCommand,
|
|
1245
|
+
searchCommand,
|
|
1246
|
+
sketchCommand
|
|
1247
|
+
};
|
|
1248
|
+
//# sourceMappingURL=index.js.map
|