simplemdg-dev-cli 1.5.1 → 2.4.4

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.
Files changed (140) hide show
  1. package/README.md +65 -243
  2. package/USER_GUIDE.md +55 -249
  3. package/dist/commands/cds.command.js +69 -60
  4. package/dist/commands/cds.command.js.map +1 -1
  5. package/dist/commands/cf-db.command.d.ts +2 -0
  6. package/dist/commands/cf-db.command.js +606 -0
  7. package/dist/commands/cf-db.command.js.map +1 -0
  8. package/dist/commands/cf.command.js +1625 -198
  9. package/dist/commands/cf.command.js.map +1 -1
  10. package/dist/commands/gitlab.command.d.ts +2 -0
  11. package/dist/commands/gitlab.command.js +351 -0
  12. package/dist/commands/gitlab.command.js.map +1 -0
  13. package/dist/commands/npmrc.command.js +50 -44
  14. package/dist/commands/npmrc.command.js.map +1 -1
  15. package/dist/core/cache.d.ts +1 -1
  16. package/dist/core/cache.js +58 -31
  17. package/dist/core/cache.js.map +1 -1
  18. package/dist/core/cds.js +32 -22
  19. package/dist/core/cds.js.map +1 -1
  20. package/dist/core/cf-env-parser.d.ts +1 -1
  21. package/dist/core/cf-env-parser.js +4 -1
  22. package/dist/core/cf-env-parser.js.map +1 -1
  23. package/dist/core/cf.d.ts +1 -1
  24. package/dist/core/cf.js +46 -31
  25. package/dist/core/cf.js.map +1 -1
  26. package/dist/core/db/db-btp.d.ts +48 -0
  27. package/dist/core/db/db-btp.js +162 -0
  28. package/dist/core/db/db-btp.js.map +1 -0
  29. package/dist/core/db/db-cache.d.ts +35 -0
  30. package/dist/core/db/db-cache.js +164 -0
  31. package/dist/core/db/db-cache.js.map +1 -0
  32. package/dist/core/db/db-connection.d.ts +22 -0
  33. package/dist/core/db/db-connection.js +73 -0
  34. package/dist/core/db/db-connection.js.map +1 -0
  35. package/dist/core/db/db-crypto.d.ts +3 -0
  36. package/dist/core/db/db-crypto.js +54 -0
  37. package/dist/core/db/db-crypto.js.map +1 -0
  38. package/dist/core/db/db-hana-adapter.d.ts +32 -0
  39. package/dist/core/db/db-hana-adapter.js +243 -0
  40. package/dist/core/db/db-hana-adapter.js.map +1 -0
  41. package/dist/core/db/db-metadata.d.ts +25 -0
  42. package/dist/core/db/db-metadata.js +150 -0
  43. package/dist/core/db/db-metadata.js.map +1 -0
  44. package/dist/core/db/db-postgres-adapter.d.ts +30 -0
  45. package/dist/core/db/db-postgres-adapter.js +245 -0
  46. package/dist/core/db/db-postgres-adapter.js.map +1 -0
  47. package/dist/core/db/db-query-files.d.ts +20 -0
  48. package/dist/core/db/db-query-files.js +106 -0
  49. package/dist/core/db/db-query-files.js.map +1 -0
  50. package/dist/core/db/db-query-history.d.ts +5 -0
  51. package/dist/core/db/db-query-history.js +49 -0
  52. package/dist/core/db/db-query-history.js.map +1 -0
  53. package/dist/core/db/db-row.d.ts +22 -0
  54. package/dist/core/db/db-row.js +70 -0
  55. package/dist/core/db/db-row.js.map +1 -0
  56. package/dist/core/db/db-studio-html.d.ts +4 -0
  57. package/dist/core/db/db-studio-html.js +437 -0
  58. package/dist/core/db/db-studio-html.js.map +1 -0
  59. package/dist/core/db/db-studio-server.d.ts +11 -0
  60. package/dist/core/db/db-studio-server.js +465 -0
  61. package/dist/core/db/db-studio-server.js.map +1 -0
  62. package/dist/core/db/db-types.d.ts +174 -0
  63. package/dist/core/db/db-types.js +3 -0
  64. package/dist/core/db/db-types.js.map +1 -0
  65. package/dist/core/db/db-vcap-parser.d.ts +7 -0
  66. package/dist/core/db/db-vcap-parser.js +137 -0
  67. package/dist/core/db/db-vcap-parser.js.map +1 -0
  68. package/dist/core/doctor.d.ts +1 -1
  69. package/dist/core/doctor.js +14 -8
  70. package/dist/core/doctor.js.map +1 -1
  71. package/dist/core/guide.js +31 -26
  72. package/dist/core/guide.js.map +1 -1
  73. package/dist/core/install.d.ts +1 -1
  74. package/dist/core/install.js +17 -11
  75. package/dist/core/install.js.map +1 -1
  76. package/dist/core/navigator.d.ts +17 -0
  77. package/dist/core/navigator.js +140 -0
  78. package/dist/core/navigator.js.map +1 -0
  79. package/dist/core/npmrc.js +29 -16
  80. package/dist/core/npmrc.js.map +1 -1
  81. package/dist/core/process.js +11 -6
  82. package/dist/core/process.js.map +1 -1
  83. package/dist/core/prompts.js +16 -8
  84. package/dist/core/prompts.js.map +1 -1
  85. package/dist/core/repository.d.ts +1 -1
  86. package/dist/core/repository.js +16 -9
  87. package/dist/core/repository.js.map +1 -1
  88. package/dist/core/scanner.d.ts +1 -1
  89. package/dist/core/scanner.js +13 -7
  90. package/dist/core/scanner.js.map +1 -1
  91. package/dist/core/tooling.d.ts +28 -0
  92. package/dist/core/tooling.js +168 -0
  93. package/dist/core/tooling.js.map +1 -0
  94. package/dist/core/types.js +2 -1
  95. package/dist/core/version-conflict.d.ts +2 -2
  96. package/dist/core/version-conflict.js +11 -6
  97. package/dist/core/version-conflict.js.map +1 -1
  98. package/dist/index.js +65 -48
  99. package/dist/index.js.map +1 -1
  100. package/dist/types-local.js +2 -1
  101. package/package.json +12 -6
  102. package/src/commands/cds.command.ts +529 -0
  103. package/src/commands/cf-db.command.ts +636 -0
  104. package/src/commands/cf.command.ts +3345 -0
  105. package/src/commands/gitlab.command.ts +373 -0
  106. package/src/commands/npmrc.command.ts +581 -0
  107. package/src/core/cache.ts +332 -0
  108. package/src/core/cds.ts +278 -0
  109. package/src/core/cf-env-parser.ts +131 -0
  110. package/src/core/cf.ts +271 -0
  111. package/src/core/db/db-btp.ts +207 -0
  112. package/src/core/db/db-cache.ts +215 -0
  113. package/src/core/db/db-connection.ts +79 -0
  114. package/src/core/db/db-crypto.ts +53 -0
  115. package/src/core/db/db-hana-adapter.ts +294 -0
  116. package/src/core/db/db-metadata.ts +174 -0
  117. package/src/core/db/db-postgres-adapter.ts +275 -0
  118. package/src/core/db/db-query-files.ts +130 -0
  119. package/src/core/db/db-query-history.ts +53 -0
  120. package/src/core/db/db-row.ts +93 -0
  121. package/src/core/db/db-studio-html.ts +439 -0
  122. package/src/core/db/db-studio-server.ts +559 -0
  123. package/src/core/db/db-types.ts +195 -0
  124. package/src/core/db/db-vcap-parser.ts +182 -0
  125. package/src/core/doctor.ts +70 -0
  126. package/src/core/guide.ts +261 -0
  127. package/src/core/install.ts +91 -0
  128. package/src/core/navigator.ts +164 -0
  129. package/src/core/npmrc.ts +171 -0
  130. package/src/core/process.ts +75 -0
  131. package/src/core/prompts.ts +225 -0
  132. package/src/core/repository.ts +36 -0
  133. package/src/core/scanner.ts +41 -0
  134. package/src/core/tooling.ts +207 -0
  135. package/src/core/types.ts +152 -0
  136. package/src/core/version-conflict.ts +46 -0
  137. package/src/index.ts +460 -0
  138. package/src/types/external.d.ts +3 -0
  139. package/src/types-local.ts +11 -0
  140. package/tsconfig.json +17 -0
@@ -0,0 +1,636 @@
1
+ import readline from "node:readline";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import prompts from "prompts";
6
+ import { Command } from "commander";
7
+ import { searchableSelectChoice } from "../core/prompts";
8
+ import { startStudioServer } from "../core/db/db-studio-server";
9
+ import {
10
+ duplicateConnection,
11
+ getResolvedConnection,
12
+ listPublicConnections,
13
+ removeConnection,
14
+ renameConnection,
15
+ upsertConnectionFromDraft,
16
+ } from "../core/db/db-cache";
17
+ import type { TConnectionDraft } from "../core/db/db-cache";
18
+ import { createAdapter, testConnectionProfile } from "../core/db/db-connection";
19
+ import {
20
+ buildDraftFromCandidate,
21
+ detectAppDatabaseServices,
22
+ ensureCloudFoundrySession,
23
+ getCloudFoundryTargetSummary,
24
+ listCloudFoundryAppsWithCache,
25
+ } from "../core/db/db-btp";
26
+ import { describeServiceCandidate } from "../core/db/db-vcap-parser";
27
+ import { ensureExternalTool } from "../core/tooling";
28
+ import { analyzeSqlSafety, appendSafeLimit } from "../core/db/db-metadata";
29
+ import { saveQuery } from "../core/db/db-query-files";
30
+ import { appendQueryHistory } from "../core/db/db-query-history";
31
+ import type { IDatabaseAdapter, TDatabaseQueryResult, TDatabaseType, TResolvedDatabaseConnection } from "../core/db/db-types";
32
+
33
+ type TStudioCommandOptions = { port?: string; readOnly?: boolean; timeout?: string };
34
+ type TImportCommandOptions = { app?: string; service?: string };
35
+
36
+ function validateRequired(value: string): true | string {
37
+ return value.trim() ? true : "Value is required";
38
+ }
39
+
40
+ function resolvedFromDraft(draft: TConnectionDraft): TResolvedDatabaseConnection {
41
+ const now = new Date().toISOString();
42
+ return {
43
+ id: "candidate",
44
+ name: draft.name,
45
+ type: draft.type,
46
+ region: draft.region,
47
+ org: draft.org,
48
+ space: draft.space,
49
+ app: draft.app,
50
+ serviceName: draft.serviceName,
51
+ servicePlan: draft.servicePlan,
52
+ host: draft.host,
53
+ port: draft.port,
54
+ database: draft.database,
55
+ schema: draft.schema,
56
+ username: draft.username,
57
+ password: draft.password,
58
+ ssl: draft.ssl,
59
+ sslValidateCertificate: draft.sslValidateCertificate,
60
+ createdAt: now,
61
+ updatedAt: now,
62
+ };
63
+ }
64
+
65
+ async function chooseConnectionId(message: string): Promise<string> {
66
+ const connections = await listPublicConnections();
67
+
68
+ if (connections.length === 0) {
69
+ throw new Error("No DB connections cached. Run: smdg cf db import");
70
+ }
71
+
72
+ return searchableSelectChoice({
73
+ message,
74
+ choices: connections.map((connection) => ({
75
+ title: `${connection.name} · ${connection.type} · ${connection.host}`,
76
+ value: connection.id,
77
+ })),
78
+ allowCustomValue: false,
79
+ });
80
+ }
81
+
82
+ function formatCell(value: unknown, maxWidth: number): string {
83
+ const text = value === null || value === undefined
84
+ ? ""
85
+ : typeof value === "object"
86
+ ? JSON.stringify(value)
87
+ : String(value);
88
+
89
+ if (maxWidth > 0 && text.length > maxWidth) {
90
+ return `${text.slice(0, maxWidth - 1)}…`;
91
+ }
92
+
93
+ return text;
94
+ }
95
+
96
+ function renderResultTable(result: TDatabaseQueryResult, options?: { showFull?: boolean }): void {
97
+ if (result.rows.length === 0) {
98
+ console.log(chalk.gray(result.affectedRows != null ? `Affected rows: ${result.affectedRows}` : "No rows."));
99
+ return;
100
+ }
101
+
102
+ const fields = result.fields.length > 0 ? result.fields : Object.keys(result.rows[0]);
103
+ const maxWidth = options?.showFull ? 0 : 48;
104
+ const widths = fields.map((field) => field.length);
105
+
106
+ const renderedRows = result.rows.map((row) =>
107
+ fields.map((field, index) => {
108
+ const cell = formatCell(row[field], maxWidth);
109
+ widths[index] = Math.max(widths[index], cell.length);
110
+ return cell;
111
+ }),
112
+ );
113
+
114
+ const header = fields.map((field, index) => chalk.cyan(field.padEnd(widths[index]))).join(" ");
115
+ const separator = fields.map((_, index) => "-".repeat(widths[index])).join(" ");
116
+ console.log(header);
117
+ console.log(chalk.gray(separator));
118
+
119
+ for (const row of renderedRows) {
120
+ console.log(row.map((cell, index) => cell.padEnd(widths[index])).join(" "));
121
+ }
122
+
123
+ console.log(chalk.gray(`\n${result.rowCount} row(s) · ${result.durationMs}ms${result.truncated ? " · truncated" : ""}`));
124
+ }
125
+
126
+ async function exportRowsInteractively(result: TDatabaseQueryResult): Promise<void> {
127
+ if (result.rows.length === 0) {
128
+ return;
129
+ }
130
+
131
+ const choice = await searchableSelectChoice({
132
+ message: "Export result?",
133
+ choices: [
134
+ { title: "No export", value: "none" },
135
+ { title: "CSV file", value: "csv" },
136
+ { title: "JSON file", value: "json" },
137
+ ],
138
+ allowCustomValue: false,
139
+ });
140
+
141
+ if (choice === "none") {
142
+ return;
143
+ }
144
+
145
+ const fields = result.fields.length > 0 ? result.fields : Object.keys(result.rows[0]);
146
+ const defaultName = choice === "csv" ? "query-result.csv" : "query-result.json";
147
+ const response = await prompts({ type: "text", name: "file", message: "Output file", initial: defaultName });
148
+ const outputFile = String(response.file || defaultName).trim();
149
+ const outputPath = path.resolve(process.cwd(), outputFile);
150
+
151
+ if (choice === "json") {
152
+ await fs.writeFile(outputPath, JSON.stringify(result.rows, null, 2), "utf8");
153
+ } else {
154
+ const escapeCell = (value: unknown): string => {
155
+ const text = value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
156
+ return `"${text.replace(/"/g, '""')}"`;
157
+ };
158
+ const csv = [
159
+ fields.map(escapeCell).join(","),
160
+ ...result.rows.map((row) => fields.map((field) => escapeCell(row[field])).join(",")),
161
+ ].join("\n");
162
+ await fs.writeFile(outputPath, csv, "utf8");
163
+ }
164
+
165
+ console.log(chalk.green(`Exported ${result.rows.length} row(s) to ${outputPath}`));
166
+ }
167
+
168
+ async function runStudioCommand(options: TStudioCommandOptions): Promise<void> {
169
+ const handle = await startStudioServer({
170
+ port: options.port ? Number(options.port) : undefined,
171
+ readOnly: Boolean(options.readOnly),
172
+ queryTimeoutMs: options.timeout ? Number(options.timeout) : undefined,
173
+ });
174
+
175
+ const shutdown = async (): Promise<void> => {
176
+ console.log("");
177
+ console.log(chalk.gray("Stopping DB Studio..."));
178
+ await handle.close();
179
+ process.exit(0);
180
+ };
181
+
182
+ process.on("SIGINT", () => void shutdown());
183
+ process.on("SIGTERM", () => void shutdown());
184
+ }
185
+
186
+ async function runImportCommand(options: TImportCommandOptions): Promise<void> {
187
+ await ensureExternalTool("cf");
188
+ const session = await ensureCloudFoundrySession();
189
+
190
+ if (!session.loggedIn) {
191
+ console.log(chalk.yellow(session.message ?? "Cloud Foundry login is required."));
192
+ throw new Error("Run: smdg cf login");
193
+ }
194
+
195
+ const target = await getCloudFoundryTargetSummary();
196
+ console.log(chalk.gray(`Target: ${target.region ?? "?"} · ${target.org ?? "?"} / ${target.space ?? "?"}`));
197
+
198
+ const appName = options.app?.trim() || await (async () => {
199
+ const apps = await listCloudFoundryAppsWithCache({});
200
+ if (apps.length === 0) {
201
+ throw new Error("No apps found in current CF target.");
202
+ }
203
+ return searchableSelectChoice({
204
+ message: "Select BTP app",
205
+ choices: apps.map((app) => ({ title: `${app.name}${app.requestedState ? ` · ${app.requestedState}` : ""}`, value: app.name })),
206
+ validateCustomValue: validateRequired,
207
+ customValueTitle: (value) => `Use typed app name: ${value}`,
208
+ });
209
+ })();
210
+
211
+ console.log(chalk.gray(`Reading cf env ${appName}...`));
212
+ const candidates = await detectAppDatabaseServices(appName);
213
+
214
+ const selectedIndex = options.service
215
+ ? String(candidates.findIndex((candidate) => candidate.serviceName === options.service))
216
+ : await searchableSelectChoice({
217
+ message: "Select database service to import",
218
+ choices: candidates.map((candidate, index) => ({ title: describeServiceCandidate(candidate), value: String(index) })),
219
+ allowCustomValue: false,
220
+ });
221
+
222
+ const candidate = candidates[Number(selectedIndex)];
223
+
224
+ if (!candidate) {
225
+ throw new Error("No database service selected.");
226
+ }
227
+
228
+ const draft = buildDraftFromCandidate(candidate, {
229
+ region: target.region,
230
+ org: target.org,
231
+ space: target.space,
232
+ app: appName,
233
+ });
234
+
235
+ console.log(chalk.gray("Testing connection..."));
236
+ const testResult = await testConnectionProfile(resolvedFromDraft(draft));
237
+
238
+ if (testResult.success) {
239
+ console.log(chalk.green(`Connection OK (${testResult.serverVersion ?? ""}) in ${testResult.durationMs}ms`));
240
+ } else {
241
+ console.log(chalk.yellow(`Connection test failed: ${testResult.message}`));
242
+ const proceed = await prompts({
243
+ type: "confirm",
244
+ name: "save",
245
+ message: "Save the connection anyway?",
246
+ initial: true,
247
+ });
248
+
249
+ if (!proceed.save) {
250
+ console.log(chalk.gray("Import cancelled."));
251
+ return;
252
+ }
253
+ }
254
+
255
+ const profile = await upsertConnectionFromDraft(draft);
256
+ console.log(chalk.green(`Saved connection: ${profile.name}`));
257
+ console.log(chalk.gray(`Type: ${profile.type} · Host: ${profile.host} · Schema/DB: ${profile.schema ?? profile.database ?? "-"}`));
258
+ console.log(chalk.gray("Password is encrypted in ~/.simplemdg/db-connections.json"));
259
+ }
260
+
261
+ async function runConnectionsCommand(): Promise<void> {
262
+ for (;;) {
263
+ const connections = await listPublicConnections();
264
+
265
+ if (connections.length === 0) {
266
+ console.log(chalk.yellow("No DB connections cached. Run: smdg cf db import"));
267
+ return;
268
+ }
269
+
270
+ const action = await searchableSelectChoice({
271
+ message: "DB connections",
272
+ choices: [
273
+ { title: "List connections", value: "list" },
274
+ { title: "Test connection", value: "test" },
275
+ { title: "Show connection info (no password)", value: "info" },
276
+ { title: "Rename connection", value: "rename" },
277
+ { title: "Duplicate connection", value: "duplicate" },
278
+ { title: "Remove connection", value: "remove" },
279
+ { title: "Exit", value: "exit" },
280
+ ],
281
+ allowCustomValue: false,
282
+ });
283
+
284
+ if (action === "exit") {
285
+ return;
286
+ }
287
+
288
+ if (action === "list") {
289
+ for (const connection of connections) {
290
+ console.log(`${chalk.bold(connection.name)} · ${connection.type} · ${connection.host}:${connection.port} · ${connection.org ?? "-"}/${connection.space ?? "-"} · app=${connection.app ?? "-"}`);
291
+ }
292
+ console.log("");
293
+ continue;
294
+ }
295
+
296
+ const id = await chooseConnectionId("Select connection");
297
+
298
+ if (action === "test") {
299
+ const resolved = await getResolvedConnection(id);
300
+ const result = await testConnectionProfile(resolved);
301
+ console.log(result.success
302
+ ? chalk.green(`OK (${result.serverVersion ?? ""}) in ${result.durationMs}ms`)
303
+ : chalk.red(`Failed: ${result.message}`));
304
+ } else if (action === "info") {
305
+ const connection = connections.find((item) => item.id === id);
306
+ if (connection) {
307
+ console.log(JSON.stringify(connection, null, 2));
308
+ }
309
+ } else if (action === "rename") {
310
+ const response = await prompts({ type: "text", name: "name", message: "New name", validate: validateRequired });
311
+ if (response.name) {
312
+ await renameConnection(id, String(response.name).trim());
313
+ console.log(chalk.green("Renamed."));
314
+ }
315
+ } else if (action === "duplicate") {
316
+ const copy = await duplicateConnection(id);
317
+ console.log(chalk.green(`Duplicated as: ${copy.name}`));
318
+ } else if (action === "remove") {
319
+ const confirm = await prompts({ type: "confirm", name: "ok", message: "Remove this connection?", initial: false });
320
+ if (confirm.ok) {
321
+ await removeConnection(id);
322
+ console.log(chalk.green("Removed."));
323
+ }
324
+ }
325
+
326
+ console.log("");
327
+ }
328
+ }
329
+
330
+ async function runQueryCommand(): Promise<void> {
331
+ const connectionId = await chooseConnectionId("Select connection for query");
332
+ const resolved = await getResolvedConnection(connectionId);
333
+ const adapter = createAdapter(resolved);
334
+
335
+ try {
336
+ await adapter.connect();
337
+ const response = await prompts({ type: "text", name: "sql", message: "SQL", validate: validateRequired });
338
+ const sql = String(response.sql ?? "").trim();
339
+
340
+ if (!sql) {
341
+ return;
342
+ }
343
+
344
+ const safety = analyzeSqlSafety(sql, { readOnly: false });
345
+
346
+ if (safety.isDestructive) {
347
+ const confirm = await prompts({ type: "confirm", name: "ok", message: `${safety.reason ?? "Dangerous statement."} Run anyway?`, initial: false });
348
+ if (!confirm.ok) {
349
+ console.log(chalk.gray("Cancelled."));
350
+ return;
351
+ }
352
+ }
353
+
354
+ const effectiveSql = appendSafeLimit(adapter.type, sql, 1000);
355
+ const result = await adapter.runQuery(effectiveSql, { maxRows: 1000 });
356
+ renderResultTable(result);
357
+ await appendQueryHistory({ connectionId, connectionName: resolved.name, connectionType: adapter.type, sql, durationMs: result.durationMs, success: true, rowCount: result.rowCount });
358
+ await exportRowsInteractively(result);
359
+ } catch (error) {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ await appendQueryHistory({ connectionId, connectionName: resolved.name, connectionType: adapter.type, sql: "", durationMs: 0, success: false, error: message }).catch(() => undefined);
362
+ throw error;
363
+ } finally {
364
+ await adapter.disconnect();
365
+ }
366
+ }
367
+
368
+ function printConsoleHelp(): void {
369
+ console.log(chalk.gray([
370
+ "Commands:",
371
+ " /connect switch connection",
372
+ " /schemas list schemas",
373
+ " /tables list tables/views in current schema",
374
+ " /desc TABLE describe table columns",
375
+ " /top TABLE select top rows",
376
+ " /count TABLE count rows",
377
+ " /save NAME save last SQL as a query file",
378
+ " /history show query history (recent)",
379
+ " /export csv export last result to CSV",
380
+ " /export json export last result to JSON",
381
+ " /full toggle full-value display",
382
+ " /clear clear screen",
383
+ " /help show this help",
384
+ " /exit quit",
385
+ "Type SQL directly to run it.",
386
+ ].join("\n")));
387
+ }
388
+
389
+ async function runConsoleCommand(): Promise<void> {
390
+ let connectionId = await chooseConnectionId("Select connection for console");
391
+ let resolved = await getResolvedConnection(connectionId);
392
+ let adapter: IDatabaseAdapter = createAdapter(resolved);
393
+ await adapter.connect();
394
+ let schema = resolved.schema ?? "";
395
+ let lastResult: TDatabaseQueryResult | undefined;
396
+ let lastSql = "";
397
+ let showFull = false;
398
+
399
+ console.log(chalk.green(`Connected: ${resolved.name} (${resolved.type})`));
400
+ printConsoleHelp();
401
+
402
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "sql> " });
403
+
404
+ const runAndRender = async (sql: string): Promise<void> => {
405
+ const safety = analyzeSqlSafety(sql, { readOnly: false });
406
+ if (safety.isDestructive) {
407
+ const confirmed = await new Promise<boolean>((resolve) => {
408
+ rl.question(chalk.yellow(`${safety.reason ?? "Dangerous statement."} Run anyway? (y/N) `), (answer) => resolve(/^y(es)?$/i.test(answer.trim())));
409
+ });
410
+ if (!confirmed) {
411
+ console.log(chalk.gray("Cancelled."));
412
+ return;
413
+ }
414
+ }
415
+
416
+ try {
417
+ const result = await adapter.runQuery(appendSafeLimit(adapter.type, sql, 1000), { maxRows: 1000 });
418
+ lastResult = result;
419
+ lastSql = sql;
420
+ renderResultTable(result, { showFull });
421
+ await appendQueryHistory({ connectionId, connectionName: resolved.name, connectionType: adapter.type, sql, durationMs: result.durationMs, success: true, rowCount: result.rowCount }).catch(() => undefined);
422
+ } catch (error) {
423
+ const message = error instanceof Error ? error.message : String(error);
424
+ console.log(chalk.red(message));
425
+ await appendQueryHistory({ connectionId, connectionName: resolved.name, connectionType: adapter.type, sql, durationMs: 0, success: false, error: message }).catch(() => undefined);
426
+ }
427
+ };
428
+
429
+ const handleCommand = async (line: string): Promise<boolean> => {
430
+ const [command, ...rest] = line.trim().split(/\s+/);
431
+ const argument = rest.join(" ");
432
+
433
+ switch (command) {
434
+ case "/exit":
435
+ case "/quit":
436
+ return true;
437
+ case "/help":
438
+ printConsoleHelp();
439
+ return false;
440
+ case "/clear":
441
+ console.clear();
442
+ return false;
443
+ case "/full":
444
+ showFull = !showFull;
445
+ console.log(chalk.gray(`Full-value display: ${showFull ? "ON" : "OFF"}`));
446
+ return false;
447
+ case "/schemas": {
448
+ const schemas = await adapter.listSchemas();
449
+ console.log(schemas.map((item) => `${item.name}${item.isSystem ? chalk.gray(" (system)") : ""}`).join("\n"));
450
+ return false;
451
+ }
452
+ case "/tables": {
453
+ const objects = await adapter.listObjects({ schema, kinds: ["table", "view"] });
454
+ console.log(objects.map((object) => `${object.kind === "view" ? "[V]" : "[T]"} ${object.name}`).join("\n") || chalk.gray("No tables/views."));
455
+ return false;
456
+ }
457
+ case "/desc": {
458
+ if (!argument) { console.log(chalk.gray("Usage: /desc TABLE")); return false; }
459
+ const columns = await adapter.listColumns(schema, argument);
460
+ renderResultTable({ fields: ["name", "dataType", "nullable", "isPrimaryKey"], rows: columns.map((column) => ({ name: column.name, dataType: column.dataType, nullable: column.nullable, isPrimaryKey: Boolean(column.isPrimaryKey) })), rowCount: columns.length, durationMs: 0 }, { showFull });
461
+ return false;
462
+ }
463
+ case "/top": {
464
+ if (!argument) { console.log(chalk.gray("Usage: /top TABLE")); return false; }
465
+ await runAndRender(`SELECT * FROM ${adapter.buildQualifiedName(schema, argument)} LIMIT 100`);
466
+ return false;
467
+ }
468
+ case "/count": {
469
+ if (!argument) { console.log(chalk.gray("Usage: /count TABLE")); return false; }
470
+ const count = await adapter.countRows(schema, argument);
471
+ console.log(`${count}`);
472
+ return false;
473
+ }
474
+ case "/save": {
475
+ if (!lastSql) { console.log(chalk.gray("No SQL to save yet.")); return false; }
476
+ const name = argument || `console-${new Date().toISOString().slice(0, 19)}`;
477
+ const saved = await saveQuery({ name, sql: lastSql, connectionId, connectionType: adapter.type });
478
+ console.log(chalk.green(`Saved query: ${saved.name}`));
479
+ return false;
480
+ }
481
+ case "/history": {
482
+ const { listQueryHistory } = await import("../core/db/db-query-history");
483
+ const items = await listQueryHistory(20);
484
+ console.log(items.map((item) => `${item.timestamp.slice(0, 19)} · ${item.success ? "ok" : "fail"} · ${item.sql.replace(/\s+/g, " ").slice(0, 80)}`).join("\n") || chalk.gray("No history."));
485
+ return false;
486
+ }
487
+ case "/export": {
488
+ if (!lastResult) { console.log(chalk.gray("No result to export.")); return false; }
489
+ await exportRowsInteractively(lastResult);
490
+ return false;
491
+ }
492
+ case "/connect": {
493
+ connectionId = await chooseConnectionId("Select connection");
494
+ await adapter.disconnect();
495
+ resolved = await getResolvedConnection(connectionId);
496
+ adapter = createAdapter(resolved);
497
+ await adapter.connect();
498
+ schema = resolved.schema ?? "";
499
+ console.log(chalk.green(`Connected: ${resolved.name} (${resolved.type})`));
500
+ return false;
501
+ }
502
+ default:
503
+ console.log(chalk.gray(`Unknown command: ${command}. Type /help`));
504
+ return false;
505
+ }
506
+ };
507
+
508
+ await new Promise<void>((resolve) => {
509
+ rl.prompt();
510
+ rl.on("line", (line) => {
511
+ const trimmed = line.trim();
512
+ const work = async (): Promise<void> => {
513
+ if (!trimmed) {
514
+ return;
515
+ }
516
+ if (trimmed.startsWith("/")) {
517
+ const shouldExit = await handleCommand(trimmed);
518
+ if (shouldExit) {
519
+ rl.close();
520
+ return;
521
+ }
522
+ } else {
523
+ await runAndRender(trimmed);
524
+ }
525
+ };
526
+
527
+ work().catch((error: unknown) => console.log(chalk.red(error instanceof Error ? error.message : String(error)))).finally(() => rl.prompt());
528
+ });
529
+ rl.on("close", () => resolve());
530
+ });
531
+
532
+ await adapter.disconnect();
533
+ console.log(chalk.gray("Console closed."));
534
+ }
535
+
536
+ async function runAddConnectionCommand(): Promise<void> {
537
+ const type = (await searchableSelectChoice({
538
+ message: "Database type",
539
+ choices: [
540
+ { title: "PostgreSQL", value: "postgresql" },
541
+ { title: "SAP HANA", value: "hana" },
542
+ ],
543
+ allowCustomValue: false,
544
+ })) as TDatabaseType;
545
+
546
+ const answers = await prompts([
547
+ { type: "text", name: "name", message: "Connection name", validate: validateRequired },
548
+ { type: "text", name: "host", message: "Host", validate: validateRequired },
549
+ { type: "text", name: "port", message: "Port", initial: type === "hana" ? "443" : "5432", validate: (value: string) => /^\d+$/.test(value.trim()) ? true : "Port must be a number" },
550
+ { type: "text", name: "database", message: "Database (optional)" },
551
+ { type: "text", name: "schema", message: "Schema (optional)", initial: type === "postgresql" ? "public" : "" },
552
+ { type: "text", name: "username", message: "Username", validate: validateRequired },
553
+ { type: "password", name: "password", message: "Password", validate: validateRequired },
554
+ { type: "confirm", name: "ssl", message: "Use SSL?", initial: true },
555
+ ]);
556
+
557
+ if (!answers.name || !answers.host || !answers.username || !answers.password) {
558
+ console.log(chalk.gray("Cancelled."));
559
+ return;
560
+ }
561
+
562
+ const draft: TConnectionDraft = {
563
+ name: String(answers.name).trim(),
564
+ type,
565
+ host: String(answers.host).trim(),
566
+ port: Number(answers.port) || (type === "hana" ? 443 : 5432),
567
+ database: String(answers.database ?? "").trim() || undefined,
568
+ schema: String(answers.schema ?? "").trim() || undefined,
569
+ username: String(answers.username).trim(),
570
+ password: String(answers.password),
571
+ ssl: Boolean(answers.ssl),
572
+ sslValidateCertificate: false,
573
+ };
574
+
575
+ console.log(chalk.gray("Testing connection..."));
576
+ const testResult = await testConnectionProfile(resolvedFromDraft(draft));
577
+
578
+ if (testResult.success) {
579
+ console.log(chalk.green(`Connection OK (${testResult.serverVersion ?? ""}) in ${testResult.durationMs}ms`));
580
+ } else {
581
+ console.log(chalk.yellow(`Connection test failed: ${testResult.message}`));
582
+ const proceed = await prompts({ type: "confirm", name: "save", message: "Save the connection anyway?", initial: true });
583
+ if (!proceed.save) {
584
+ console.log(chalk.gray("Cancelled."));
585
+ return;
586
+ }
587
+ }
588
+
589
+ const profile = await upsertConnectionFromDraft(draft);
590
+ console.log(chalk.green(`Saved connection: ${profile.name}`));
591
+ console.log(chalk.gray("Password is encrypted in ~/.simplemdg/db-connections.json"));
592
+ }
593
+
594
+ export function registerCloudFoundryDbCommands(cfCommand: Command): void {
595
+ const db = cfCommand
596
+ .command("db")
597
+ .description("BTP database explorer: import connections, browse schemas, run SQL, and open DB Studio");
598
+
599
+ db
600
+ .command("studio")
601
+ .description("Open the local SimpleMDG CF DB Studio (browser UI for HANA/PostgreSQL)")
602
+ .option("--port <port>", "Preferred local port (auto-falls back if busy)", "45888")
603
+ .option("--read-only", "Start in read-only mode (blocks write/DDL statements)")
604
+ .option("--timeout <ms>", "Query timeout in milliseconds", "30000")
605
+ .action(runStudioCommand);
606
+
607
+ db
608
+ .command("add")
609
+ .alias("new")
610
+ .description("Add a database connection manually (host/port/user/password) — like a DBeaver connection")
611
+ .action(runAddConnectionCommand);
612
+
613
+ db
614
+ .command("import")
615
+ .description("Import a HANA/PostgreSQL connection from a BTP app's cf env (VCAP_SERVICES)")
616
+ .option("--app <appName>", "BTP app name")
617
+ .option("--service <serviceName>", "Service instance name to import")
618
+ .action(runImportCommand);
619
+
620
+ db
621
+ .command("connections")
622
+ .alias("conn")
623
+ .description("Manage cached DB connections (list, test, rename, duplicate, remove)")
624
+ .action(runConnectionsCommand);
625
+
626
+ db
627
+ .command("query")
628
+ .description("Run a single SQL query against a cached connection and print the result")
629
+ .action(runQueryCommand);
630
+
631
+ db
632
+ .command("console")
633
+ .alias("repl")
634
+ .description("Open an interactive terminal SQL console with slash commands")
635
+ .action(runConsoleCommand);
636
+ }