@prisma-next/cli 0.3.0-dev.4 → 0.3.0-dev.41

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 (106) hide show
  1. package/README.md +125 -40
  2. package/dist/cli.d.mts +1 -0
  3. package/dist/cli.js +1 -2376
  4. package/dist/cli.mjs +168 -0
  5. package/dist/cli.mjs.map +1 -0
  6. package/dist/client-Lm9Q6aQM.mjs +694 -0
  7. package/dist/client-Lm9Q6aQM.mjs.map +1 -0
  8. package/dist/commands/contract-emit.d.mts +7 -0
  9. package/dist/commands/contract-emit.d.mts.map +1 -0
  10. package/dist/commands/contract-emit.mjs +140 -0
  11. package/dist/commands/contract-emit.mjs.map +1 -0
  12. package/dist/commands/db-init.d.mts +7 -0
  13. package/dist/commands/db-init.d.mts.map +1 -0
  14. package/dist/commands/db-init.mjs +179 -0
  15. package/dist/commands/db-init.mjs.map +1 -0
  16. package/dist/commands/db-introspect.d.mts +7 -0
  17. package/dist/commands/db-introspect.d.mts.map +1 -0
  18. package/dist/commands/db-introspect.mjs +120 -0
  19. package/dist/commands/db-introspect.mjs.map +1 -0
  20. package/dist/commands/db-schema-verify.d.mts +7 -0
  21. package/dist/commands/db-schema-verify.d.mts.map +1 -0
  22. package/dist/commands/db-schema-verify.mjs +116 -0
  23. package/dist/commands/db-schema-verify.mjs.map +1 -0
  24. package/dist/commands/db-sign.d.mts +7 -0
  25. package/dist/commands/db-sign.d.mts.map +1 -0
  26. package/dist/commands/db-sign.mjs +138 -0
  27. package/dist/commands/db-sign.mjs.map +1 -0
  28. package/dist/commands/db-verify.d.mts +7 -0
  29. package/dist/commands/db-verify.d.mts.map +1 -0
  30. package/dist/commands/db-verify.mjs +129 -0
  31. package/dist/commands/db-verify.mjs.map +1 -0
  32. package/dist/config-loader-CnnWuluc.mjs +42 -0
  33. package/dist/config-loader-CnnWuluc.mjs.map +1 -0
  34. package/dist/{config-loader.d.ts → config-loader.d.mts} +5 -2
  35. package/dist/config-loader.d.mts.map +1 -0
  36. package/dist/config-loader.mjs +3 -0
  37. package/dist/exports/config-types.d.mts +2 -0
  38. package/dist/exports/config-types.mjs +3 -0
  39. package/dist/exports/control-api.d.mts +451 -0
  40. package/dist/exports/control-api.d.mts.map +1 -0
  41. package/dist/exports/control-api.mjs +59 -0
  42. package/dist/exports/control-api.mjs.map +1 -0
  43. package/dist/{index.d.ts → exports/index.d.mts} +7 -6
  44. package/dist/exports/index.d.mts.map +1 -0
  45. package/dist/exports/index.mjs +130 -0
  46. package/dist/exports/index.mjs.map +1 -0
  47. package/dist/result-handler-BZPY7HX4.mjs +1029 -0
  48. package/dist/result-handler-BZPY7HX4.mjs.map +1 -0
  49. package/package.json +50 -38
  50. package/src/cli.ts +260 -0
  51. package/src/commands/contract-emit.ts +267 -0
  52. package/src/commands/db-init.ts +355 -0
  53. package/src/commands/db-introspect.ts +227 -0
  54. package/src/commands/db-schema-verify.ts +238 -0
  55. package/src/commands/db-sign.ts +279 -0
  56. package/src/commands/db-verify.ts +259 -0
  57. package/src/config-loader.ts +76 -0
  58. package/src/control-api/client.ts +591 -0
  59. package/src/control-api/operations/contract-emit.ts +103 -0
  60. package/src/control-api/operations/db-init.ts +281 -0
  61. package/src/control-api/types.ts +493 -0
  62. package/src/exports/config-types.ts +6 -0
  63. package/src/exports/control-api.ts +51 -0
  64. package/src/exports/index.ts +4 -0
  65. package/src/load-ts-contract.ts +222 -0
  66. package/src/utils/cli-errors.ts +26 -0
  67. package/src/utils/command-helpers.ts +26 -0
  68. package/src/utils/framework-components.ts +177 -0
  69. package/src/utils/global-flags.ts +75 -0
  70. package/src/utils/output.ts +1477 -0
  71. package/src/utils/progress-adapter.ts +86 -0
  72. package/src/utils/result-handler.ts +44 -0
  73. package/dist/chunk-464LNZCE.js +0 -134
  74. package/dist/chunk-464LNZCE.js.map +0 -1
  75. package/dist/chunk-BZMBKEEQ.js +0 -997
  76. package/dist/chunk-BZMBKEEQ.js.map +0 -1
  77. package/dist/chunk-HWYQOCAJ.js +0 -47
  78. package/dist/chunk-HWYQOCAJ.js.map +0 -1
  79. package/dist/chunk-ZKYEJROM.js +0 -94
  80. package/dist/chunk-ZKYEJROM.js.map +0 -1
  81. package/dist/cli.js.map +0 -1
  82. package/dist/commands/contract-emit.d.ts +0 -5
  83. package/dist/commands/contract-emit.js +0 -9
  84. package/dist/commands/contract-emit.js.map +0 -1
  85. package/dist/commands/db-init.d.ts +0 -5
  86. package/dist/commands/db-init.js +0 -341
  87. package/dist/commands/db-init.js.map +0 -1
  88. package/dist/commands/db-introspect.d.ts +0 -5
  89. package/dist/commands/db-introspect.js +0 -190
  90. package/dist/commands/db-introspect.js.map +0 -1
  91. package/dist/commands/db-schema-verify.d.ts +0 -5
  92. package/dist/commands/db-schema-verify.js +0 -164
  93. package/dist/commands/db-schema-verify.js.map +0 -1
  94. package/dist/commands/db-sign.d.ts +0 -5
  95. package/dist/commands/db-sign.js +0 -199
  96. package/dist/commands/db-sign.js.map +0 -1
  97. package/dist/commands/db-verify.d.ts +0 -5
  98. package/dist/commands/db-verify.js +0 -173
  99. package/dist/commands/db-verify.js.map +0 -1
  100. package/dist/config-loader.js +0 -7
  101. package/dist/config-loader.js.map +0 -1
  102. package/dist/config-types.d.ts +0 -1
  103. package/dist/config-types.js +0 -6
  104. package/dist/config-types.js.map +0 -1
  105. package/dist/index.js +0 -175
  106. package/dist/index.js.map +0 -1
@@ -0,0 +1,1477 @@
1
+ import { relative } from 'node:path';
2
+ import { ifDefined } from '@prisma-next/utils/defined';
3
+ import { bgGreen, blue, bold, cyan, dim, green, magenta, red, yellow } from 'colorette';
4
+ import type { Command } from 'commander';
5
+ import stringWidth from 'string-width';
6
+ import stripAnsi from 'strip-ansi';
7
+ import wrapAnsi from 'wrap-ansi';
8
+ // EmitContractResult type for CLI output formatting (includes file paths)
9
+ export interface EmitContractResult {
10
+ readonly storageHash: string;
11
+ readonly executionHash?: string;
12
+ readonly profileHash: string;
13
+ readonly outDir: string;
14
+ readonly files: {
15
+ readonly json: string;
16
+ readonly dts: string;
17
+ };
18
+ readonly timings: {
19
+ readonly total: number;
20
+ };
21
+ }
22
+
23
+ import type { CoreSchemaView, SchemaTreeNode } from '@prisma-next/core-control-plane/schema-view';
24
+ import type {
25
+ IntrospectSchemaResult,
26
+ SchemaVerificationNode,
27
+ SignDatabaseResult,
28
+ VerifyDatabaseResult,
29
+ VerifyDatabaseSchemaResult,
30
+ } from '@prisma-next/core-control-plane/types';
31
+ import type { CliErrorConflict, CliErrorEnvelope } from './cli-errors';
32
+ import { getLongDescription } from './command-helpers';
33
+ import type { GlobalFlags } from './global-flags';
34
+
35
+ // ============================================================================
36
+ // Helper Functions
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Formats a timestamp for output.
41
+ */
42
+ function formatTimestamp(): string {
43
+ return new Date().toISOString();
44
+ }
45
+
46
+ /**
47
+ * Creates a prefix string if timestamps are enabled.
48
+ */
49
+ function createPrefix(flags: GlobalFlags): string {
50
+ return flags.timestamps ? `[${formatTimestamp()}] ` : '';
51
+ }
52
+
53
+ /**
54
+ * Checks if verbose output is enabled at the specified level.
55
+ */
56
+ function isVerbose(flags: GlobalFlags, level: 1 | 2): boolean {
57
+ return (flags.verbose ?? 0) >= level;
58
+ }
59
+
60
+ /**
61
+ * Creates a color-aware formatter function.
62
+ * Returns a function that applies the color only if colors are enabled.
63
+ */
64
+ function createColorFormatter<T extends (text: string) => string>(
65
+ useColor: boolean,
66
+ colorFn: T,
67
+ ): (text: string) => string {
68
+ return useColor ? colorFn : (text: string) => text;
69
+ }
70
+
71
+ /**
72
+ * Formats text with dim styling if colors are enabled.
73
+ */
74
+ function formatDim(useColor: boolean, text: string): string {
75
+ return useColor ? dim(text) : text;
76
+ }
77
+
78
+ // ============================================================================
79
+ // Emit Output Formatters
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Formats human-readable output for contract emit.
84
+ */
85
+ export function formatEmitOutput(result: EmitContractResult, flags: GlobalFlags): string {
86
+ if (flags.quiet) {
87
+ return '';
88
+ }
89
+
90
+ const lines: string[] = [];
91
+ const prefix = createPrefix(flags);
92
+
93
+ // Convert absolute paths to relative paths from cwd
94
+ const jsonPath = relative(process.cwd(), result.files.json);
95
+ const dtsPath = relative(process.cwd(), result.files.dts);
96
+
97
+ lines.push(`${prefix}✔ Emitted contract.json → ${jsonPath}`);
98
+ lines.push(`${prefix}✔ Emitted contract.d.ts → ${dtsPath}`);
99
+ lines.push(`${prefix} storageHash: ${result.storageHash}`);
100
+ if (result.executionHash) {
101
+ lines.push(`${prefix} executionHash: ${result.executionHash}`);
102
+ }
103
+ if (result.profileHash) {
104
+ lines.push(`${prefix} profileHash: ${result.profileHash}`);
105
+ }
106
+ if (isVerbose(flags, 1)) {
107
+ lines.push(`${prefix} Total time: ${result.timings.total}ms`);
108
+ }
109
+
110
+ return lines.join('\n');
111
+ }
112
+
113
+ /**
114
+ * Formats JSON output for contract emit.
115
+ */
116
+ export function formatEmitJson(result: EmitContractResult): string {
117
+ const output = {
118
+ ok: true,
119
+ storageHash: result.storageHash,
120
+ ...ifDefined('executionHash', result.executionHash),
121
+ ...(result.profileHash ? { profileHash: result.profileHash } : {}),
122
+ outDir: result.outDir,
123
+ files: result.files,
124
+ timings: result.timings,
125
+ };
126
+
127
+ return JSON.stringify(output, null, 2);
128
+ }
129
+
130
+ // ============================================================================
131
+ // Error Output Formatters
132
+ // ============================================================================
133
+
134
+ /**
135
+ * Formats error output for human-readable display.
136
+ */
137
+ export function formatErrorOutput(error: CliErrorEnvelope, flags: GlobalFlags): string {
138
+ const lines: string[] = [];
139
+ const prefix = createPrefix(flags);
140
+ const useColor = flags.color !== false;
141
+ const formatRed = createColorFormatter(useColor, red);
142
+ const formatDimText = (text: string) => formatDim(useColor, text);
143
+
144
+ lines.push(`${prefix}${formatRed('✖')} ${error.summary} (${error.code})`);
145
+
146
+ if (error.why) {
147
+ lines.push(`${prefix}${formatDimText(` Why: ${error.why}`)}`);
148
+ }
149
+ if (error.fix) {
150
+ lines.push(`${prefix}${formatDimText(` Fix: ${error.fix}`)}`);
151
+ }
152
+ if (error.where?.path) {
153
+ const whereLine = error.where.line
154
+ ? `${error.where.path}:${error.where.line}`
155
+ : error.where.path;
156
+ lines.push(`${prefix}${formatDimText(` Where: ${whereLine}`)}`);
157
+ }
158
+ // Show conflicts list if present (always show a short list; show full list when verbose)
159
+ if (error.meta?.['conflicts']) {
160
+ const conflicts = error.meta['conflicts'] as readonly CliErrorConflict[];
161
+ if (conflicts.length > 0) {
162
+ const maxToShow = isVerbose(flags, 1) ? conflicts.length : Math.min(3, conflicts.length);
163
+ const header = isVerbose(flags, 1)
164
+ ? ' Conflicts:'
165
+ : ` Conflicts (showing ${maxToShow} of ${conflicts.length}):`;
166
+ lines.push(`${prefix}${formatDimText(header)}`);
167
+ for (const conflict of conflicts.slice(0, maxToShow)) {
168
+ lines.push(`${prefix}${formatDimText(` - [${conflict.kind}] ${conflict.summary}`)}`);
169
+ }
170
+ if (!isVerbose(flags, 1) && conflicts.length > maxToShow) {
171
+ lines.push(`${prefix}${formatDimText(' Re-run with -v/--verbose to see all conflicts')}`);
172
+ }
173
+ }
174
+ }
175
+ // Show issues list if present (always show a short list; show full list when verbose)
176
+ if (error.meta?.['issues']) {
177
+ const issues = error.meta['issues'] as readonly { kind?: string; message?: string }[];
178
+ if (issues.length > 0) {
179
+ const maxToShow = isVerbose(flags, 1) ? issues.length : Math.min(3, issues.length);
180
+ const header = isVerbose(flags, 1)
181
+ ? ' Issues:'
182
+ : ` Issues (showing ${maxToShow} of ${issues.length}):`;
183
+ lines.push(`${prefix}${formatDimText(header)}`);
184
+ for (const issue of issues.slice(0, maxToShow)) {
185
+ const kind = issue.kind ?? 'issue';
186
+ const message = issue.message ?? '';
187
+ lines.push(`${prefix}${formatDimText(` - [${kind}] ${message}`)}`);
188
+ }
189
+ if (!isVerbose(flags, 1) && issues.length > maxToShow) {
190
+ lines.push(`${prefix}${formatDimText(' Re-run with -v/--verbose to see all issues')}`);
191
+ }
192
+ }
193
+ }
194
+ if (error.docsUrl && isVerbose(flags, 1)) {
195
+ lines.push(formatDimText(error.docsUrl));
196
+ }
197
+ if (isVerbose(flags, 2) && error.meta) {
198
+ lines.push(`${prefix}${formatDimText(` Meta: ${JSON.stringify(error.meta, null, 2)}`)}`);
199
+ }
200
+
201
+ return lines.join('\n');
202
+ }
203
+
204
+ /**
205
+ * Formats error output as JSON.
206
+ */
207
+ export function formatErrorJson(error: CliErrorEnvelope): string {
208
+ return JSON.stringify(error, null, 2);
209
+ }
210
+
211
+ // ============================================================================
212
+ // Verify Output Formatters
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Formats human-readable output for database verify.
217
+ */
218
+ export function formatVerifyOutput(result: VerifyDatabaseResult, flags: GlobalFlags): string {
219
+ if (flags.quiet) {
220
+ return '';
221
+ }
222
+
223
+ const lines: string[] = [];
224
+ const prefix = createPrefix(flags);
225
+ const useColor = flags.color !== false;
226
+ const formatGreen = createColorFormatter(useColor, green);
227
+ const formatRed = createColorFormatter(useColor, red);
228
+ const formatDimText = (text: string) => formatDim(useColor, text);
229
+
230
+ if (result.ok) {
231
+ lines.push(`${prefix}${formatGreen('✔')} ${result.summary}`);
232
+ lines.push(`${prefix}${formatDimText(` storageHash: ${result.contract.storageHash}`)}`);
233
+ if (result.contract.profileHash) {
234
+ lines.push(`${prefix}${formatDimText(` profileHash: ${result.contract.profileHash}`)}`);
235
+ }
236
+ } else {
237
+ lines.push(`${prefix}${formatRed('✖')} ${result.summary} (${result.code})`);
238
+ }
239
+
240
+ if (isVerbose(flags, 1)) {
241
+ if (result.codecCoverageSkipped) {
242
+ lines.push(
243
+ `${prefix}${formatDimText(' Codec coverage check skipped (helper returned no supported types)')}`,
244
+ );
245
+ }
246
+ lines.push(`${prefix}${formatDimText(` Total time: ${result.timings.total}ms`)}`);
247
+ }
248
+
249
+ return lines.join('\n');
250
+ }
251
+
252
+ /**
253
+ * Formats JSON output for database verify.
254
+ */
255
+ export function formatVerifyJson(result: VerifyDatabaseResult): string {
256
+ const output = {
257
+ ok: result.ok,
258
+ ...(result.code ? { code: result.code } : {}),
259
+ summary: result.summary,
260
+ contract: result.contract,
261
+ ...(result.marker ? { marker: result.marker } : {}),
262
+ target: result.target,
263
+ ...(result.missingCodecs ? { missingCodecs: result.missingCodecs } : {}),
264
+ ...(result.meta ? { meta: result.meta } : {}),
265
+ timings: result.timings,
266
+ };
267
+
268
+ return JSON.stringify(output, null, 2);
269
+ }
270
+
271
+ /**
272
+ * Formats JSON output for database introspection.
273
+ */
274
+ export function formatIntrospectJson(result: IntrospectSchemaResult<unknown>): string {
275
+ return JSON.stringify(result, null, 2);
276
+ }
277
+
278
+ /**
279
+ * Renders a schema tree structure from CoreSchemaView.
280
+ * Matches the style of renderSchemaVerificationTree for consistency.
281
+ */
282
+ function renderSchemaTree(
283
+ node: SchemaTreeNode,
284
+ flags: GlobalFlags,
285
+ options: {
286
+ readonly isLast: boolean;
287
+ readonly prefix: string;
288
+ readonly useColor: boolean;
289
+ readonly formatDimText: (text: string) => string;
290
+ readonly isRoot?: boolean;
291
+ },
292
+ ): string[] {
293
+ const { isLast, prefix, useColor, formatDimText, isRoot = false } = options;
294
+ const lines: string[] = [];
295
+
296
+ // Format node label with color based on kind (matching schema-verify style)
297
+ let formattedLabel: string = node.label;
298
+
299
+ if (useColor) {
300
+ switch (node.kind) {
301
+ case 'root':
302
+ formattedLabel = bold(node.label);
303
+ break;
304
+ case 'entity': {
305
+ // Parse "table tableName" format - color "table" dim, tableName cyan
306
+ const tableMatch = node.label.match(/^table\s+(.+)$/);
307
+ if (tableMatch?.[1]) {
308
+ const tableName = tableMatch[1];
309
+ formattedLabel = `${dim('table')} ${cyan(tableName)}`;
310
+ } else {
311
+ // Fallback: color entire label with cyan
312
+ formattedLabel = cyan(node.label);
313
+ }
314
+ break;
315
+ }
316
+ case 'collection': {
317
+ // "columns" grouping node - dim the label
318
+ formattedLabel = dim(node.label);
319
+ break;
320
+ }
321
+ case 'field': {
322
+ // Parse column name format: "columnName: typeDisplay (nullability)"
323
+ // Color code: column name (cyan), type (default), nullability (dim)
324
+ const columnMatch = node.label.match(/^([^:]+):\s*(.+)$/);
325
+ if (columnMatch?.[1] && columnMatch[2]) {
326
+ const columnName = columnMatch[1];
327
+ const rest = columnMatch[2];
328
+ // Parse rest: "typeDisplay (nullability)"
329
+ const typeMatch = rest.match(/^([^\s(]+)\s*(\([^)]+\))$/);
330
+ if (typeMatch?.[1] && typeMatch[2]) {
331
+ const typeDisplay = typeMatch[1];
332
+ const nullability = typeMatch[2];
333
+ formattedLabel = `${cyan(columnName)}: ${typeDisplay} ${dim(nullability)}`;
334
+ } else {
335
+ // Fallback if format doesn't match
336
+ formattedLabel = `${cyan(columnName)}: ${rest}`;
337
+ }
338
+ } else {
339
+ formattedLabel = node.label;
340
+ }
341
+ break;
342
+ }
343
+ case 'index': {
344
+ // Parse index/unique constraint/primary key formats
345
+ // "primary key: columnName" -> dim "primary key", cyan columnName
346
+ const pkMatch = node.label.match(/^primary key:\s*(.+)$/);
347
+ if (pkMatch?.[1]) {
348
+ const columnNames = pkMatch[1];
349
+ formattedLabel = `${dim('primary key')}: ${cyan(columnNames)}`;
350
+ } else {
351
+ // "unique name" -> dim "unique", cyan "name"
352
+ const uniqueMatch = node.label.match(/^unique\s+(.+)$/);
353
+ if (uniqueMatch?.[1]) {
354
+ const name = uniqueMatch[1];
355
+ formattedLabel = `${dim('unique')} ${cyan(name)}`;
356
+ } else {
357
+ // "index name" or "unique index name" -> dim label prefix, cyan name
358
+ const indexMatch = node.label.match(/^(unique\s+)?index\s+(.+)$/);
359
+ if (indexMatch?.[2]) {
360
+ const prefix = indexMatch[1] ? `${dim('unique')} ` : '';
361
+ const name = indexMatch[2];
362
+ formattedLabel = `${prefix}${dim('index')} ${cyan(name)}`;
363
+ } else {
364
+ formattedLabel = dim(node.label);
365
+ }
366
+ }
367
+ }
368
+ break;
369
+ }
370
+ case 'extension': {
371
+ // Parse extension message formats similar to schema-verify
372
+ // "extensionName extension is enabled" -> cyan extensionName, dim rest
373
+ const extMatch = node.label.match(/^([^\s]+)\s+(extension is enabled)$/);
374
+ if (extMatch?.[1] && extMatch[2]) {
375
+ const extName = extMatch[1];
376
+ const rest = extMatch[2];
377
+ formattedLabel = `${cyan(extName)} ${dim(rest)}`;
378
+ } else {
379
+ // Fallback: color entire label with magenta
380
+ formattedLabel = magenta(node.label);
381
+ }
382
+ break;
383
+ }
384
+ default:
385
+ formattedLabel = node.label;
386
+ break;
387
+ }
388
+ }
389
+
390
+ // Root node renders without tree characters or │ prefix
391
+ if (isRoot) {
392
+ lines.push(formattedLabel);
393
+ } else {
394
+ // Determine tree character for this node
395
+ const treeChar = isLast ? '└' : '├';
396
+ const treePrefix = `${prefix}${formatDimText(treeChar)}─ `;
397
+ // Root's direct children don't have │ prefix, other nodes do
398
+ // But if prefix already contains │ (for nested children), don't add another
399
+ const isRootChild = prefix === '';
400
+ // Check if prefix already contains │ (strip ANSI codes for comparison)
401
+ const prefixWithoutAnsi = stripAnsi(prefix);
402
+ const prefixHasVerticalBar = prefixWithoutAnsi.includes('│');
403
+ if (isRootChild) {
404
+ lines.push(`${treePrefix}${formattedLabel}`);
405
+ } else if (prefixHasVerticalBar) {
406
+ // Prefix already has │, so just use treePrefix directly
407
+ lines.push(`${treePrefix}${formattedLabel}`);
408
+ } else {
409
+ lines.push(`${formatDimText('│')} ${treePrefix}${formattedLabel}`);
410
+ }
411
+ }
412
+
413
+ // Render children if present
414
+ if (node.children && node.children.length > 0) {
415
+ // For root node, children start with no prefix (they'll add their own tree characters)
416
+ // For other nodes, calculate child prefix based on whether this is last
417
+ const childPrefix = isRoot ? '' : isLast ? `${prefix} ` : `${prefix}${formatDimText('│')} `;
418
+ for (let i = 0; i < node.children.length; i++) {
419
+ const child = node.children[i];
420
+ if (!child) continue;
421
+ const isLastChild = i === node.children.length - 1;
422
+ const childLines = renderSchemaTree(child, flags, {
423
+ isLast: isLastChild,
424
+ prefix: childPrefix,
425
+ useColor,
426
+ formatDimText,
427
+ isRoot: false,
428
+ });
429
+ lines.push(...childLines);
430
+ }
431
+ }
432
+
433
+ return lines;
434
+ }
435
+
436
+ /**
437
+ * Formats human-readable output for database introspection.
438
+ */
439
+ export function formatIntrospectOutput(
440
+ result: IntrospectSchemaResult<unknown>,
441
+ schemaView: CoreSchemaView | undefined,
442
+ flags: GlobalFlags,
443
+ ): string {
444
+ if (flags.quiet) {
445
+ return '';
446
+ }
447
+
448
+ const lines: string[] = [];
449
+ const prefix = createPrefix(flags);
450
+ const useColor = flags.color !== false;
451
+ const formatDimText = (text: string) => formatDim(useColor, text);
452
+
453
+ if (schemaView) {
454
+ // Render tree structure - root node is special (no tree characters)
455
+ const treeLines = renderSchemaTree(schemaView.root, flags, {
456
+ isLast: true,
457
+ prefix: '',
458
+ useColor,
459
+ formatDimText,
460
+ isRoot: true,
461
+ });
462
+ // Apply prefix (for timestamps) to each tree line
463
+ const prefixedTreeLines = treeLines.map((line) => `${prefix}${line}`);
464
+ lines.push(...prefixedTreeLines);
465
+ } else {
466
+ // Fallback: print summary when toSchemaView is not available
467
+ lines.push(`${prefix}✔ ${result.summary}`);
468
+ if (isVerbose(flags, 1)) {
469
+ lines.push(`${prefix} Target: ${result.target.familyId}/${result.target.id}`);
470
+ if (result.meta?.dbUrl) {
471
+ lines.push(`${prefix} Database: ${result.meta.dbUrl}`);
472
+ }
473
+ }
474
+ }
475
+
476
+ // Add timings in verbose mode
477
+ if (isVerbose(flags, 1)) {
478
+ lines.push(`${prefix}${formatDimText(` Total time: ${result.timings.total}ms`)}`);
479
+ }
480
+
481
+ return lines.join('\n');
482
+ }
483
+
484
+ /**
485
+ * Renders a schema verification tree structure from SchemaVerificationNode.
486
+ * Similar to renderSchemaTree but for verification nodes with status-based colors and glyphs.
487
+ */
488
+ function renderSchemaVerificationTree(
489
+ node: SchemaVerificationNode,
490
+ flags: GlobalFlags,
491
+ options: {
492
+ readonly isLast: boolean;
493
+ readonly prefix: string;
494
+ readonly useColor: boolean;
495
+ readonly formatDimText: (text: string) => string;
496
+ readonly isRoot?: boolean;
497
+ },
498
+ ): string[] {
499
+ const { isLast, prefix, useColor, formatDimText, isRoot = false } = options;
500
+ const lines: string[] = [];
501
+
502
+ // Format status glyph and color based on status
503
+ let statusGlyph = '';
504
+ let statusColor: (text: string) => string = (text) => text;
505
+ if (useColor) {
506
+ switch (node.status) {
507
+ case 'pass':
508
+ statusGlyph = '✔';
509
+ statusColor = green;
510
+ break;
511
+ case 'warn':
512
+ statusGlyph = '⚠';
513
+ statusColor = (text) => (useColor ? yellow(text) : text);
514
+ break;
515
+ case 'fail':
516
+ statusGlyph = '✖';
517
+ statusColor = red;
518
+ break;
519
+ }
520
+ } else {
521
+ switch (node.status) {
522
+ case 'pass':
523
+ statusGlyph = '✔';
524
+ break;
525
+ case 'warn':
526
+ statusGlyph = '⚠';
527
+ break;
528
+ case 'fail':
529
+ statusGlyph = '✖';
530
+ break;
531
+ }
532
+ }
533
+
534
+ // Format node label with color based on kind
535
+ // For column nodes, we need to parse the name to color code different parts
536
+ let labelColor: (text: string) => string = (text) => text;
537
+ let formattedLabel: string = node.name;
538
+
539
+ if (useColor) {
540
+ switch (node.kind) {
541
+ case 'contract':
542
+ case 'schema':
543
+ labelColor = bold;
544
+ formattedLabel = labelColor(node.name);
545
+ break;
546
+ case 'table': {
547
+ // Parse "table tableName" format - color "table" dim, tableName cyan
548
+ const tableMatch = node.name.match(/^table\s+(.+)$/);
549
+ if (tableMatch?.[1]) {
550
+ const tableName = tableMatch[1];
551
+ formattedLabel = `${dim('table')} ${cyan(tableName)}`;
552
+ } else {
553
+ formattedLabel = dim(node.name);
554
+ }
555
+ break;
556
+ }
557
+ case 'columns':
558
+ labelColor = dim;
559
+ formattedLabel = labelColor(node.name);
560
+ break;
561
+ case 'column': {
562
+ // Parse column name format: "columnName: contractType → nativeType (nullability)"
563
+ // Color code: column name (cyan), contract type (default), native type (dim), nullability (dim)
564
+ const columnMatch = node.name.match(/^([^:]+):\s*(.+)$/);
565
+ if (columnMatch?.[1] && columnMatch[2]) {
566
+ const columnName = columnMatch[1];
567
+ const rest = columnMatch[2];
568
+ // Parse rest: "contractType → nativeType (nullability)"
569
+ // Match contract type (can contain /, @, etc.), arrow, native type, then nullability in parentheses
570
+ const typeMatch = rest.match(/^([^\s→]+)\s*→\s*([^\s(]+)\s*(\([^)]+\))$/);
571
+ if (typeMatch?.[1] && typeMatch[2] && typeMatch[3]) {
572
+ const contractType = typeMatch[1];
573
+ const nativeType = typeMatch[2];
574
+ const nullability = typeMatch[3];
575
+ formattedLabel = `${cyan(columnName)}: ${contractType} → ${dim(nativeType)} ${dim(nullability)}`;
576
+ } else {
577
+ // Fallback if format doesn't match (e.g., no native type or no nullability)
578
+ formattedLabel = `${cyan(columnName)}: ${rest}`;
579
+ }
580
+ } else {
581
+ formattedLabel = node.name;
582
+ }
583
+ break;
584
+ }
585
+ case 'type':
586
+ case 'nullability':
587
+ labelColor = (text) => text; // Default color
588
+ formattedLabel = labelColor(node.name);
589
+ break;
590
+ case 'primaryKey': {
591
+ // Parse "primary key: columnName" format - color "primary key" dim, columnName cyan
592
+ const pkMatch = node.name.match(/^primary key:\s*(.+)$/);
593
+ if (pkMatch?.[1]) {
594
+ const columnNames = pkMatch[1];
595
+ formattedLabel = `${dim('primary key')}: ${cyan(columnNames)}`;
596
+ } else {
597
+ formattedLabel = dim(node.name);
598
+ }
599
+ break;
600
+ }
601
+ case 'foreignKey':
602
+ case 'unique':
603
+ case 'index':
604
+ labelColor = dim;
605
+ formattedLabel = labelColor(node.name);
606
+ break;
607
+ case 'extension': {
608
+ // Parse specific extension message formats
609
+ // "database is postgres" -> dim "database is", cyan "postgres"
610
+ const dbMatch = node.name.match(/^database is\s+(.+)$/);
611
+ if (dbMatch?.[1]) {
612
+ const dbName = dbMatch[1];
613
+ formattedLabel = `${dim('database is')} ${cyan(dbName)}`;
614
+ } else {
615
+ // "vector extension is enabled" -> dim everything except extension name
616
+ // Match pattern: "extensionName extension is enabled"
617
+ const extMatch = node.name.match(/^([^\s]+)\s+(extension is enabled)$/);
618
+ if (extMatch?.[1] && extMatch[2]) {
619
+ const extName = extMatch[1];
620
+ const rest = extMatch[2];
621
+ formattedLabel = `${cyan(extName)} ${dim(rest)}`;
622
+ } else {
623
+ // Fallback: color entire name with magenta
624
+ labelColor = magenta;
625
+ formattedLabel = labelColor(node.name);
626
+ }
627
+ }
628
+ break;
629
+ }
630
+ default:
631
+ formattedLabel = node.name;
632
+ break;
633
+ }
634
+ } else {
635
+ formattedLabel = node.name;
636
+ }
637
+
638
+ const statusGlyphColored = statusColor(statusGlyph);
639
+
640
+ // Build the label with optional message for failure/warn nodes
641
+ let nodeLabel = formattedLabel;
642
+ if (
643
+ (node.status === 'fail' || node.status === 'warn') &&
644
+ node.message &&
645
+ node.message.length > 0
646
+ ) {
647
+ // Always show message for failure/warn nodes - it provides crucial context
648
+ // For parent nodes, the message summarizes child failures
649
+ // For leaf nodes, the message explains the specific issue
650
+ const messageText = formatDimText(`(${node.message})`);
651
+ nodeLabel = `${formattedLabel} ${messageText}`;
652
+ }
653
+
654
+ // Root node renders without tree characters or │ prefix
655
+ if (isRoot) {
656
+ lines.push(`${statusGlyphColored} ${nodeLabel}`);
657
+ } else {
658
+ // Determine tree character for this node
659
+ const treeChar = isLast ? '└' : '├';
660
+ const treePrefix = `${prefix}${formatDimText(treeChar)}─ `;
661
+ // Root's direct children don't have │ prefix, other nodes do
662
+ const isRootChild = prefix === '';
663
+ // Check if prefix already contains │ (strip ANSI codes for comparison)
664
+ const prefixWithoutAnsi = stripAnsi(prefix);
665
+ const prefixHasVerticalBar = prefixWithoutAnsi.includes('│');
666
+ if (isRootChild) {
667
+ lines.push(`${treePrefix}${statusGlyphColored} ${nodeLabel}`);
668
+ } else if (prefixHasVerticalBar) {
669
+ // Prefix already has │, so just use treePrefix directly
670
+ lines.push(`${treePrefix}${statusGlyphColored} ${nodeLabel}`);
671
+ } else {
672
+ lines.push(`${formatDimText('│')} ${treePrefix}${statusGlyphColored} ${nodeLabel}`);
673
+ }
674
+ }
675
+
676
+ // Render children if present
677
+ if (node.children && node.children.length > 0) {
678
+ // For root node, children start with no prefix (they'll add their own tree characters)
679
+ // For other nodes, calculate child prefix based on whether this is last
680
+ const childPrefix = isRoot ? '' : isLast ? `${prefix} ` : `${prefix}${formatDimText('│')} `;
681
+ for (let i = 0; i < node.children.length; i++) {
682
+ const child = node.children[i];
683
+ if (!child) continue;
684
+ const isLastChild = i === node.children.length - 1;
685
+ const childLines = renderSchemaVerificationTree(child, flags, {
686
+ isLast: isLastChild,
687
+ prefix: childPrefix,
688
+ useColor,
689
+ formatDimText,
690
+ isRoot: false,
691
+ });
692
+ lines.push(...childLines);
693
+ }
694
+ }
695
+
696
+ return lines;
697
+ }
698
+
699
+ /**
700
+ * Formats human-readable output for database schema verification.
701
+ */
702
+ export function formatSchemaVerifyOutput(
703
+ result: VerifyDatabaseSchemaResult,
704
+ flags: GlobalFlags,
705
+ ): string {
706
+ if (flags.quiet) {
707
+ return '';
708
+ }
709
+
710
+ const lines: string[] = [];
711
+ const prefix = createPrefix(flags);
712
+ const useColor = flags.color !== false;
713
+ const formatGreen = createColorFormatter(useColor, green);
714
+ const formatRed = createColorFormatter(useColor, red);
715
+ const formatDimText = (text: string) => formatDim(useColor, text);
716
+
717
+ // Render verification tree first
718
+ const treeLines = renderSchemaVerificationTree(result.schema.root, flags, {
719
+ isLast: true,
720
+ prefix: '',
721
+ useColor,
722
+ formatDimText,
723
+ isRoot: true,
724
+ });
725
+ // Apply prefix (for timestamps) to each tree line
726
+ const prefixedTreeLines = treeLines.map((line) => `${prefix}${line}`);
727
+ lines.push(...prefixedTreeLines);
728
+
729
+ // Add counts and timings in verbose mode
730
+ if (isVerbose(flags, 1)) {
731
+ lines.push(`${prefix}${formatDimText(` Total time: ${result.timings.total}ms`)}`);
732
+ lines.push(
733
+ `${prefix}${formatDimText(` pass=${result.schema.counts.pass} warn=${result.schema.counts.warn} fail=${result.schema.counts.fail}`)}`,
734
+ );
735
+ }
736
+
737
+ // Blank line before summary
738
+ lines.push('');
739
+
740
+ // Summary line at the end: summary with status glyph
741
+ if (result.ok) {
742
+ lines.push(`${prefix}${formatGreen('✔')} ${result.summary}`);
743
+ } else {
744
+ const codeText = result.code ? ` (${result.code})` : '';
745
+ lines.push(`${prefix}${formatRed('✖')} ${result.summary}${codeText}`);
746
+ }
747
+
748
+ return lines.join('\n');
749
+ }
750
+
751
+ /**
752
+ * Formats JSON output for database schema verification.
753
+ */
754
+ export function formatSchemaVerifyJson(result: VerifyDatabaseSchemaResult): string {
755
+ return JSON.stringify(result, null, 2);
756
+ }
757
+
758
+ // ============================================================================
759
+ // Sign Output Formatters
760
+ // ============================================================================
761
+
762
+ /**
763
+ * Formats human-readable output for database sign.
764
+ */
765
+ export function formatSignOutput(result: SignDatabaseResult, flags: GlobalFlags): string {
766
+ if (flags.quiet) {
767
+ return '';
768
+ }
769
+
770
+ const lines: string[] = [];
771
+ const prefix = createPrefix(flags);
772
+ const useColor = flags.color !== false;
773
+ const formatGreen = createColorFormatter(useColor, green);
774
+ const formatDimText = (text: string) => formatDim(useColor, text);
775
+
776
+ if (result.ok) {
777
+ // Main success message in white (not dimmed)
778
+ lines.push(`${prefix}${formatGreen('✔')} Database signed`);
779
+
780
+ // Show from -> to hashes with clear labels
781
+ const previousHash = result.marker.previous?.storageHash ?? 'none';
782
+ const currentHash = result.contract.storageHash;
783
+
784
+ lines.push(`${prefix}${formatDimText(` from: ${previousHash}`)}`);
785
+ lines.push(`${prefix}${formatDimText(` to: ${currentHash}`)}`);
786
+
787
+ if (isVerbose(flags, 1)) {
788
+ if (result.contract.profileHash) {
789
+ lines.push(`${prefix}${formatDimText(` profileHash: ${result.contract.profileHash}`)}`);
790
+ }
791
+ if (result.marker.previous?.profileHash) {
792
+ lines.push(
793
+ `${prefix}${formatDimText(` previous profileHash: ${result.marker.previous.profileHash}`)}`,
794
+ );
795
+ }
796
+ lines.push(`${prefix}${formatDimText(` Total time: ${result.timings.total}ms`)}`);
797
+ }
798
+ }
799
+
800
+ return lines.join('\n');
801
+ }
802
+
803
+ /**
804
+ * Formats JSON output for database sign.
805
+ */
806
+ export function formatSignJson(result: SignDatabaseResult): string {
807
+ return JSON.stringify(result, null, 2);
808
+ }
809
+
810
+ // ============================================================================
811
+ // DB Init Output Formatters
812
+ // ============================================================================
813
+
814
+ /**
815
+ * Result type for db init command.
816
+ */
817
+ export interface DbInitResult {
818
+ readonly ok: boolean;
819
+ readonly mode: 'plan' | 'apply';
820
+ readonly plan?: {
821
+ readonly targetId: string;
822
+ readonly destination: {
823
+ readonly storageHash: string;
824
+ readonly profileHash?: string;
825
+ };
826
+ readonly operations: readonly {
827
+ readonly id: string;
828
+ readonly label: string;
829
+ readonly operationClass: string;
830
+ }[];
831
+ };
832
+ readonly execution?: {
833
+ readonly operationsPlanned: number;
834
+ readonly operationsExecuted: number;
835
+ };
836
+ readonly marker?: {
837
+ readonly storageHash: string;
838
+ readonly profileHash?: string;
839
+ };
840
+ readonly summary: string;
841
+ readonly timings: {
842
+ readonly total: number;
843
+ };
844
+ }
845
+
846
+ /**
847
+ * Formats human-readable output for db init plan mode.
848
+ */
849
+ export function formatDbInitPlanOutput(result: DbInitResult, flags: GlobalFlags): string {
850
+ if (flags.quiet) {
851
+ return '';
852
+ }
853
+
854
+ const lines: string[] = [];
855
+ const prefix = createPrefix(flags);
856
+ const useColor = flags.color !== false;
857
+ const formatGreen = createColorFormatter(useColor, green);
858
+ const formatDimText = (text: string) => formatDim(useColor, text);
859
+
860
+ // Plan summary
861
+ const operationCount = result.plan?.operations.length ?? 0;
862
+ lines.push(`${prefix}${formatGreen('✔')} Planned ${operationCount} operation(s)`);
863
+
864
+ // Show operations tree
865
+ if (result.plan?.operations && result.plan.operations.length > 0) {
866
+ lines.push(`${prefix}${formatDimText('│')}`);
867
+ for (let i = 0; i < result.plan.operations.length; i++) {
868
+ const op = result.plan.operations[i];
869
+ if (!op) continue;
870
+ const isLast = i === result.plan.operations.length - 1;
871
+ const treeChar = isLast ? '└' : '├';
872
+ const opClass = formatDimText(`[${op.operationClass}]`);
873
+ lines.push(`${prefix}${formatDimText(treeChar)}─ ${op.label} ${opClass}`);
874
+ }
875
+ }
876
+
877
+ // Destination hash
878
+ if (result.plan?.destination) {
879
+ lines.push(`${prefix}`);
880
+ lines.push(
881
+ `${prefix}${formatDimText(`Destination hash: ${result.plan.destination.storageHash}`)}`,
882
+ );
883
+ }
884
+
885
+ // Timings in verbose mode
886
+ if (isVerbose(flags, 1)) {
887
+ lines.push(`${prefix}${formatDimText(`Total time: ${result.timings.total}ms`)}`);
888
+ }
889
+
890
+ // Note about dry run
891
+ lines.push(`${prefix}`);
892
+ lines.push(`${prefix}${formatDimText('This is a dry run. No changes were applied.')}`);
893
+ lines.push(`${prefix}${formatDimText('Run without --plan to apply changes.')}`);
894
+
895
+ return lines.join('\n');
896
+ }
897
+
898
+ /**
899
+ * Formats human-readable output for db init apply mode.
900
+ */
901
+ export function formatDbInitApplyOutput(result: DbInitResult, flags: GlobalFlags): string {
902
+ if (flags.quiet) {
903
+ return '';
904
+ }
905
+
906
+ const lines: string[] = [];
907
+ const prefix = createPrefix(flags);
908
+ const useColor = flags.color !== false;
909
+ const formatGreen = createColorFormatter(useColor, green);
910
+ const formatDimText = (text: string) => formatDim(useColor, text);
911
+
912
+ if (result.ok) {
913
+ // Success summary
914
+ const executed = result.execution?.operationsExecuted ?? 0;
915
+ lines.push(`${prefix}${formatGreen('✔')} Applied ${executed} operation(s)`);
916
+
917
+ // Marker info
918
+ if (result.marker) {
919
+ lines.push(`${prefix}${formatDimText(` Marker written: ${result.marker.storageHash}`)}`);
920
+ if (result.marker.profileHash) {
921
+ lines.push(`${prefix}${formatDimText(` Profile hash: ${result.marker.profileHash}`)}`);
922
+ }
923
+ }
924
+
925
+ // Timings in verbose mode
926
+ if (isVerbose(flags, 1)) {
927
+ lines.push(`${prefix}${formatDimText(` Total time: ${result.timings.total}ms`)}`);
928
+ }
929
+ }
930
+
931
+ return lines.join('\n');
932
+ }
933
+
934
+ /**
935
+ * Formats JSON output for db init command.
936
+ */
937
+ export function formatDbInitJson(result: DbInitResult): string {
938
+ return JSON.stringify(result, null, 2);
939
+ }
940
+
941
+ // ============================================================================
942
+ // Styled Output Formatters
943
+ // ============================================================================
944
+
945
+ /**
946
+ * Fixed width for left column in help output.
947
+ */
948
+ const LEFT_COLUMN_WIDTH = 20;
949
+
950
+ /**
951
+ * Minimum width for right column wrapping in help output.
952
+ */
953
+ const RIGHT_COLUMN_MIN_WIDTH = 40;
954
+
955
+ /**
956
+ * Maximum width for right column wrapping in help output (when terminal is wide enough).
957
+ */
958
+ const RIGHT_COLUMN_MAX_WIDTH = 90;
959
+
960
+ /**
961
+ * Gets the terminal width, or returns a default if not available.
962
+ */
963
+ function getTerminalWidth(): number {
964
+ // process.stdout.columns may be undefined in non-TTY environments
965
+ const terminalWidth = process.stdout.columns;
966
+ // Default to 80 if terminal width is not available, but allow override via env var
967
+ const defaultWidth = Number.parseInt(process.env['CLI_WIDTH'] || '80', 10);
968
+ return terminalWidth || defaultWidth;
969
+ }
970
+
971
+ /**
972
+ * Calculates the available width for the right column based on terminal width.
973
+ * Format: "│ " (2) + left column (20) + " " (2) + right column = total
974
+ * So: right column = terminal width - 2 - 20 - 2 = terminal width - 24
975
+ */
976
+ function calculateRightColumnWidth(): number {
977
+ const terminalWidth = getTerminalWidth();
978
+ const availableWidth = terminalWidth - 2 - LEFT_COLUMN_WIDTH - 2; // Subtract separators and left column
979
+ // Ensure minimum width, but don't exceed maximum
980
+ return Math.max(RIGHT_COLUMN_MIN_WIDTH, Math.min(availableWidth, RIGHT_COLUMN_MAX_WIDTH));
981
+ }
982
+
983
+ /**
984
+ * Creates an arrow segment badge with green background and white text.
985
+ * Body: green background with white "prisma-next" text
986
+ * Tip: dark grey arrow pointing right (Powerline separator)
987
+ */
988
+ function createPrismaNextBadge(useColor: boolean): string {
989
+ if (!useColor) {
990
+ return 'prisma-next';
991
+ }
992
+ // Body: green background with white text
993
+ const text = ' prisma-next ';
994
+ const body = bgGreen(bold(text));
995
+
996
+ // Use Powerline separator (U+E0B0) which creates the arrow transition effect
997
+ const separator = '\u{E0B0}';
998
+ const tip = green(separator); // Dark grey arrow tip
999
+
1000
+ return `${body}${tip}`;
1001
+ }
1002
+
1003
+ /**
1004
+ * Creates a padding function.
1005
+ */
1006
+ function createPadFunction(): (s: string, w: number) => string {
1007
+ return (s: string, w: number) => s + ' '.repeat(Math.max(0, w - s.length));
1008
+ }
1009
+
1010
+ /**
1011
+ * Formats a header line: brand + operation + intent
1012
+ */
1013
+ function formatHeaderLine(options: {
1014
+ readonly brand: string;
1015
+ readonly operation: string;
1016
+ readonly intent: string;
1017
+ }): string {
1018
+ if (options.operation) {
1019
+ return `${options.brand} ${options.operation} → ${options.intent}`;
1020
+ }
1021
+ return `${options.brand} ${options.intent}`;
1022
+ }
1023
+
1024
+ /**
1025
+ * Formats a "Read more" URL line.
1026
+ * The "Read more" label is in default color (not cyan), and the URL is blue.
1027
+ */
1028
+ function formatReadMoreLine(options: {
1029
+ readonly url: string;
1030
+ readonly maxLabelWidth: number;
1031
+ readonly useColor: boolean;
1032
+ readonly formatDimText: (text: string) => string;
1033
+ }): string {
1034
+ const pad = createPadFunction();
1035
+ const labelPadded = pad('Read more', options.maxLabelWidth);
1036
+ // Label is default color (not cyan)
1037
+ const valueColored = options.useColor ? blue(options.url) : options.url;
1038
+ return `${options.formatDimText('│')} ${labelPadded} ${valueColored}`;
1039
+ }
1040
+
1041
+ /**
1042
+ * Pads text to a fixed width, accounting for ANSI escape codes.
1043
+ * Uses string-width to measure the actual display width.
1044
+ */
1045
+ function padToFixedWidth(text: string, width: number): string {
1046
+ const actualWidth = stringWidth(text);
1047
+ const padding = Math.max(0, width - actualWidth);
1048
+ return text + ' '.repeat(padding);
1049
+ }
1050
+
1051
+ /**
1052
+ * Wraps text to fit within a specified width using wrap-ansi.
1053
+ * Preserves ANSI escape codes and breaks at word boundaries.
1054
+ */
1055
+ function wrapTextAnsi(text: string, width: number): string[] {
1056
+ const wrapped = wrapAnsi(text, width, { hard: false, trim: true });
1057
+ return wrapped.split('\n');
1058
+ }
1059
+
1060
+ /**
1061
+ * Formats a default value as "default: <value>" with dimming.
1062
+ */
1063
+ function formatDefaultValue(value: unknown, useColor: boolean): string {
1064
+ const valueStr = String(value);
1065
+ const defaultText = `default: ${valueStr}`;
1066
+ return useColor ? dim(defaultText) : defaultText;
1067
+ }
1068
+
1069
+ /**
1070
+ * Renders a command tree structure.
1071
+ * Handles both single-level (subcommands of a command) and multi-level (top-level commands with subcommands) trees.
1072
+ */
1073
+ function renderCommandTree(options: {
1074
+ readonly commands: readonly Command[];
1075
+ readonly useColor: boolean;
1076
+ readonly formatDimText: (text: string) => string;
1077
+ readonly hasItemsAfter: boolean;
1078
+ readonly continuationPrefix?: string;
1079
+ }): string[] {
1080
+ const { commands, useColor, formatDimText, hasItemsAfter, continuationPrefix } = options;
1081
+ const lines: string[] = [];
1082
+
1083
+ if (commands.length === 0) {
1084
+ return lines;
1085
+ }
1086
+
1087
+ // Format each command
1088
+ for (let i = 0; i < commands.length; i++) {
1089
+ const cmd = commands[i];
1090
+ if (!cmd) continue;
1091
+
1092
+ const subcommands = cmd.commands.filter((subcmd) => !subcmd.name().startsWith('_'));
1093
+ const isLastCommand = i === commands.length - 1;
1094
+
1095
+ if (subcommands.length > 0) {
1096
+ // Command with subcommands - show command name, then tree-structured subcommands
1097
+ const prefix = isLastCommand && !hasItemsAfter ? formatDimText('└') : formatDimText('├');
1098
+ // For top-level command, pad name to fixed width (accounting for "│ ├─ " = 5 chars)
1099
+ const treePrefix = `${prefix}─ `;
1100
+ const treePrefixWidth = stringWidth(stripAnsi(treePrefix));
1101
+ const remainingWidth = LEFT_COLUMN_WIDTH - treePrefixWidth;
1102
+ const commandNamePadded = padToFixedWidth(cmd.name(), remainingWidth);
1103
+ const commandNameColored = useColor ? cyan(commandNamePadded) : commandNamePadded;
1104
+ lines.push(`${formatDimText('│')} ${treePrefix}${commandNameColored}`);
1105
+
1106
+ for (let j = 0; j < subcommands.length; j++) {
1107
+ const subcmd = subcommands[j];
1108
+ if (!subcmd) continue;
1109
+
1110
+ const isLastSubcommand = j === subcommands.length - 1;
1111
+ const shortDescription = subcmd.description() || '';
1112
+
1113
+ // Use tree characters: └─ for last subcommand, ├─ for others
1114
+ const treeChar = isLastSubcommand ? '└' : '├';
1115
+ const continuation =
1116
+ continuationPrefix ??
1117
+ (isLastCommand && isLastSubcommand && !hasItemsAfter ? ' ' : formatDimText('│'));
1118
+ // For subcommands, account for "│ │ └─ " = 7 chars (or "│ └─ " = 6 chars if continuation is space)
1119
+ const continuationStr = continuation === ' ' ? ' ' : continuation;
1120
+ const subTreePrefix = `${continuationStr} ${formatDimText(treeChar)}─ `;
1121
+ const subTreePrefixWidth = stringWidth(stripAnsi(subTreePrefix));
1122
+ const subRemainingWidth = LEFT_COLUMN_WIDTH - subTreePrefixWidth;
1123
+ const subcommandNamePadded = padToFixedWidth(subcmd.name(), subRemainingWidth);
1124
+ const subcommandNameColored = useColor ? cyan(subcommandNamePadded) : subcommandNamePadded;
1125
+ lines.push(
1126
+ `${formatDimText('│')} ${subTreePrefix}${subcommandNameColored} ${shortDescription}`,
1127
+ );
1128
+ }
1129
+ } else {
1130
+ // Standalone command - show command name and description on same line
1131
+ const prefix = isLastCommand && !hasItemsAfter ? formatDimText('└') : formatDimText('├');
1132
+ const treePrefix = `${prefix}─ `;
1133
+ const treePrefixWidth = stringWidth(stripAnsi(treePrefix));
1134
+ const remainingWidth = LEFT_COLUMN_WIDTH - treePrefixWidth;
1135
+ const commandNamePadded = padToFixedWidth(cmd.name(), remainingWidth);
1136
+ const commandNameColored = useColor ? cyan(commandNamePadded) : commandNamePadded;
1137
+ const shortDescription = cmd.description() || '';
1138
+ lines.push(`${formatDimText('│')} ${treePrefix}${commandNameColored} ${shortDescription}`);
1139
+ }
1140
+ }
1141
+
1142
+ return lines;
1143
+ }
1144
+
1145
+ /**
1146
+ * Formats multiline description with "Prisma Next" in green.
1147
+ * Wraps at the same right-hand boundary as the right column.
1148
+ * The right edge is defined by: left column (20) + gap (2) + right column (90) = 112 characters total.
1149
+ * Since the description line starts with "│ " (2 chars), the text wraps at 112 - 2 = 110 characters.
1150
+ */
1151
+ function formatMultilineDescription(options: {
1152
+ readonly descriptionLines: readonly string[];
1153
+ readonly useColor: boolean;
1154
+ readonly formatDimText: (text: string) => string;
1155
+ }): string[] {
1156
+ const lines: string[] = [];
1157
+ const formatGreen = (text: string) => (options.useColor ? green(text) : text);
1158
+
1159
+ // Calculate wrap width to align with right edge of right column
1160
+ // Format: "│ " (2) + left column (20) + " " (2) + right column = total
1161
+ // Description line has "│ " prefix (2 chars), so text wraps at total - 2
1162
+ const rightColumnWidth = calculateRightColumnWidth();
1163
+ const totalWidth = 2 + LEFT_COLUMN_WIDTH + 2 + rightColumnWidth;
1164
+ const wrapWidth = totalWidth - 2; // Subtract "│ " prefix
1165
+
1166
+ for (const descLine of options.descriptionLines) {
1167
+ // Replace "Prisma Next" with green version if present
1168
+ const formattedLine = descLine.replace(/Prisma Next/g, (match) => formatGreen(match));
1169
+
1170
+ // Wrap the line at the same right edge as the right column
1171
+ const wrappedLines = wrapTextAnsi(formattedLine, wrapWidth);
1172
+ for (const wrappedLine of wrappedLines) {
1173
+ lines.push(`${options.formatDimText('│')} ${wrappedLine}`);
1174
+ }
1175
+ }
1176
+ return lines;
1177
+ }
1178
+
1179
+ /**
1180
+ * Formats the header in the new experimental visual style.
1181
+ * This header appears at the start of command output, showing the operation,
1182
+ * intent, documentation link, and parameters.
1183
+ */
1184
+ export function formatStyledHeader(options: {
1185
+ readonly command: string;
1186
+ readonly description: string;
1187
+ readonly url?: string;
1188
+ readonly details: ReadonlyArray<{ readonly label: string; readonly value: string }>;
1189
+ readonly flags: GlobalFlags;
1190
+ }): string {
1191
+ const lines: string[] = [];
1192
+ const useColor = options.flags.color !== false;
1193
+ const formatDimText = (text: string) => formatDim(useColor, text);
1194
+
1195
+ // Header: arrow + operation badge + intent
1196
+ const brand = createPrismaNextBadge(useColor);
1197
+ // Use full command path (e.g., "contract emit" not just "emit")
1198
+ const operation = useColor ? bold(options.command) : options.command;
1199
+ const intent = formatDimText(options.description);
1200
+ lines.push(formatHeaderLine({ brand, operation, intent }));
1201
+ lines.push(formatDimText('│')); // Vertical line separator between command and params
1202
+
1203
+ // Format details using fixed left column width (same style as help text options)
1204
+ for (const detail of options.details) {
1205
+ // Add colon to label, then pad to fixed width using padToFixedWidth for ANSI-aware padding
1206
+ const labelWithColon = `${detail.label}:`;
1207
+ const labelPadded = padToFixedWidth(labelWithColon, LEFT_COLUMN_WIDTH);
1208
+ const labelColored = useColor ? cyan(labelPadded) : labelPadded;
1209
+ lines.push(`${formatDimText('│')} ${labelColored} ${detail.value}`);
1210
+ }
1211
+
1212
+ // Add "Read more" URL if present (same style as help text)
1213
+ if (options.url) {
1214
+ lines.push(formatDimText('│')); // Separator line before "Read more"
1215
+ lines.push(
1216
+ formatReadMoreLine({
1217
+ url: options.url,
1218
+ maxLabelWidth: LEFT_COLUMN_WIDTH,
1219
+ useColor,
1220
+ formatDimText,
1221
+ }),
1222
+ );
1223
+ }
1224
+
1225
+ lines.push(formatDimText('└'));
1226
+
1227
+ return `${lines.join('\n')}\n`;
1228
+ }
1229
+
1230
+ /**
1231
+ * Formats a success message in the styled output format.
1232
+ */
1233
+ export function formatSuccessMessage(flags: GlobalFlags): string {
1234
+ const useColor = flags.color !== false;
1235
+ const formatGreen = createColorFormatter(useColor, green);
1236
+ return `${formatGreen('✔')} Success`;
1237
+ }
1238
+
1239
+ // ============================================================================
1240
+ // Help Output Formatters
1241
+ // ============================================================================
1242
+
1243
+ /**
1244
+ * Maps command paths to their documentation URLs.
1245
+ */
1246
+ function getCommandDocsUrl(commandPath: string): string | undefined {
1247
+ const docsMap: Record<string, string> = {
1248
+ 'contract emit': 'https://pris.ly/contract-emit',
1249
+ 'db verify': 'https://pris.ly/db-verify',
1250
+ };
1251
+ return docsMap[commandPath];
1252
+ }
1253
+
1254
+ /**
1255
+ * Builds the full command path from a command and its parents.
1256
+ */
1257
+ function buildCommandPath(command: Command): string {
1258
+ const parts: string[] = [];
1259
+ let current: Command | undefined = command;
1260
+ while (current && current.name() !== 'prisma-next') {
1261
+ parts.unshift(current.name());
1262
+ current = current.parent ?? undefined;
1263
+ }
1264
+ return parts.join(' ');
1265
+ }
1266
+
1267
+ /**
1268
+ * Formats help output for a command using the styled format.
1269
+ */
1270
+ export function formatCommandHelp(options: {
1271
+ readonly command: Command;
1272
+ readonly flags: GlobalFlags;
1273
+ }): string {
1274
+ const { command, flags } = options;
1275
+ const lines: string[] = [];
1276
+ const useColor = flags.color !== false;
1277
+ const formatDimText = (text: string) => formatDim(useColor, text);
1278
+
1279
+ // Build full command path (e.g., "db verify")
1280
+ const commandPath = buildCommandPath(command);
1281
+ const shortDescription = command.description() || '';
1282
+ const longDescription = getLongDescription(command);
1283
+
1284
+ // Header: "prisma-next <full-command-path> <short-description>"
1285
+ const brand = createPrismaNextBadge(useColor);
1286
+ const operation = useColor ? bold(commandPath) : commandPath;
1287
+ const intent = formatDimText(shortDescription);
1288
+ lines.push(formatHeaderLine({ brand, operation, intent }));
1289
+ lines.push(formatDimText('│')); // Vertical line separator between command and params
1290
+
1291
+ // Extract options and format them
1292
+ const optionsList = command.options.map((opt) => {
1293
+ const flags = opt.flags;
1294
+ const description = opt.description || '';
1295
+ // Commander.js stores default value in defaultValue property
1296
+ const defaultValue = (opt as { defaultValue?: unknown }).defaultValue;
1297
+ return { flags, description, defaultValue };
1298
+ });
1299
+
1300
+ // Extract subcommands if any
1301
+ const subcommands = command.commands.filter((cmd) => !cmd.name().startsWith('_'));
1302
+
1303
+ // Format subcommands as a tree if present
1304
+ if (subcommands.length > 0) {
1305
+ const hasItemsAfter = optionsList.length > 0;
1306
+ const treeLines = renderCommandTree({
1307
+ commands: subcommands,
1308
+ useColor,
1309
+ formatDimText,
1310
+ hasItemsAfter,
1311
+ });
1312
+ lines.push(...treeLines);
1313
+ }
1314
+
1315
+ // Add separator between subcommands and options if both exist
1316
+ if (subcommands.length > 0 && optionsList.length > 0) {
1317
+ lines.push(formatDimText('│'));
1318
+ }
1319
+
1320
+ // Format options with fixed width, wrapping, and default values
1321
+ if (optionsList.length > 0) {
1322
+ for (const opt of optionsList) {
1323
+ // Format flag with fixed 30-char width
1324
+ const flagsPadded = padToFixedWidth(opt.flags, LEFT_COLUMN_WIDTH);
1325
+ let flagsColored = flagsPadded;
1326
+ if (useColor) {
1327
+ // Color placeholders in magenta, then wrap in cyan
1328
+ flagsColored = flagsPadded.replace(/(<[^>]+>)/g, (match: string) => magenta(match));
1329
+ flagsColored = cyan(flagsColored);
1330
+ }
1331
+
1332
+ // Wrap description based on terminal width
1333
+ const rightColumnWidth = calculateRightColumnWidth();
1334
+ const wrappedDescription = wrapTextAnsi(opt.description, rightColumnWidth);
1335
+
1336
+ // First line: flag + first line of description
1337
+ lines.push(`${formatDimText('│')} ${flagsColored} ${wrappedDescription[0] || ''}`);
1338
+
1339
+ // Continuation lines: empty label (30 spaces) + wrapped lines
1340
+ for (let i = 1; i < wrappedDescription.length; i++) {
1341
+ const emptyLabel = ' '.repeat(LEFT_COLUMN_WIDTH);
1342
+ lines.push(`${formatDimText('│')} ${emptyLabel} ${wrappedDescription[i] || ''}`);
1343
+ }
1344
+
1345
+ // Default value line (if present)
1346
+ if (opt.defaultValue !== undefined) {
1347
+ const emptyLabel = ' '.repeat(LEFT_COLUMN_WIDTH);
1348
+ const defaultText = formatDefaultValue(opt.defaultValue, useColor);
1349
+ lines.push(`${formatDimText('│')} ${emptyLabel} ${defaultText}`);
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ // Add docs URL if available (with separator line before it)
1355
+ const docsUrl = getCommandDocsUrl(commandPath);
1356
+ if (docsUrl) {
1357
+ lines.push(formatDimText('│')); // Separator line between params and docs
1358
+ lines.push(
1359
+ formatReadMoreLine({
1360
+ url: docsUrl,
1361
+ maxLabelWidth: LEFT_COLUMN_WIDTH,
1362
+ useColor,
1363
+ formatDimText,
1364
+ }),
1365
+ );
1366
+ }
1367
+
1368
+ // Multi-line description (if present) - shown after all other content
1369
+ if (longDescription) {
1370
+ lines.push(formatDimText('│'));
1371
+ const descriptionLines = longDescription.split('\n').filter((line) => line.trim().length > 0);
1372
+ lines.push(...formatMultilineDescription({ descriptionLines, useColor, formatDimText }));
1373
+ }
1374
+
1375
+ lines.push(formatDimText('└'));
1376
+
1377
+ return `${lines.join('\n')}\n`;
1378
+ }
1379
+
1380
+ /**
1381
+ * Formats help output for the root program using the styled format.
1382
+ */
1383
+ export function formatRootHelp(options: {
1384
+ readonly program: Command;
1385
+ readonly flags: GlobalFlags;
1386
+ }): string {
1387
+ const { program, flags } = options;
1388
+ const lines: string[] = [];
1389
+ const useColor = flags.color !== false;
1390
+ const formatDimText = (text: string) => formatDim(useColor, text);
1391
+
1392
+ // Header: "prisma-next → Manage your data layer"
1393
+ const brand = createPrismaNextBadge(useColor);
1394
+ const shortDescription = 'Manage your data layer';
1395
+ const intent = formatDimText(shortDescription);
1396
+ lines.push(formatHeaderLine({ brand, operation: '', intent }));
1397
+ lines.push(formatDimText('│')); // Vertical line separator after header
1398
+
1399
+ // Extract top-level commands (exclude hidden commands starting with '_' and the 'help' command)
1400
+ const topLevelCommands = program.commands.filter(
1401
+ (cmd) => !cmd.name().startsWith('_') && cmd.name() !== 'help',
1402
+ );
1403
+
1404
+ // Extract global options (needed to determine if last command)
1405
+ const globalOptions = program.options.map((opt) => {
1406
+ const flags = opt.flags;
1407
+ const description = opt.description || '';
1408
+ // Commander.js stores default value in defaultValue property
1409
+ const defaultValue = (opt as { defaultValue?: unknown }).defaultValue;
1410
+ return { flags, description, defaultValue };
1411
+ });
1412
+
1413
+ // Build command tree
1414
+ if (topLevelCommands.length > 0) {
1415
+ const hasItemsAfter = globalOptions.length > 0;
1416
+ const treeLines = renderCommandTree({
1417
+ commands: topLevelCommands,
1418
+ useColor,
1419
+ formatDimText,
1420
+ hasItemsAfter,
1421
+ });
1422
+ lines.push(...treeLines);
1423
+ }
1424
+
1425
+ // Add separator between commands and options if both exist
1426
+ if (topLevelCommands.length > 0 && globalOptions.length > 0) {
1427
+ lines.push(formatDimText('│'));
1428
+ }
1429
+
1430
+ // Format global options with fixed width, wrapping, and default values
1431
+ if (globalOptions.length > 0) {
1432
+ for (const opt of globalOptions) {
1433
+ // Format flag with fixed 30-char width
1434
+ const flagsPadded = padToFixedWidth(opt.flags, LEFT_COLUMN_WIDTH);
1435
+ let flagsColored = flagsPadded;
1436
+ if (useColor) {
1437
+ // Color placeholders in magenta, then wrap in cyan
1438
+ flagsColored = flagsPadded.replace(/(<[^>]+>)/g, (match: string) => magenta(match));
1439
+ flagsColored = cyan(flagsColored);
1440
+ }
1441
+
1442
+ // Wrap description based on terminal width
1443
+ const rightColumnWidth = calculateRightColumnWidth();
1444
+ const wrappedDescription = wrapTextAnsi(opt.description, rightColumnWidth);
1445
+
1446
+ // First line: flag + first line of description
1447
+ lines.push(`${formatDimText('│')} ${flagsColored} ${wrappedDescription[0] || ''}`);
1448
+
1449
+ // Continuation lines: empty label (30 spaces) + wrapped lines
1450
+ for (let i = 1; i < wrappedDescription.length; i++) {
1451
+ const emptyLabel = ' '.repeat(LEFT_COLUMN_WIDTH);
1452
+ lines.push(`${formatDimText('│')} ${emptyLabel} ${wrappedDescription[i] || ''}`);
1453
+ }
1454
+
1455
+ // Default value line (if present)
1456
+ if (opt.defaultValue !== undefined) {
1457
+ const emptyLabel = ' '.repeat(LEFT_COLUMN_WIDTH);
1458
+ const defaultText = formatDefaultValue(opt.defaultValue, useColor);
1459
+ lines.push(`${formatDimText('│')} ${emptyLabel} ${defaultText}`);
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ // Multi-line description (white, not dimmed, with "Prisma Next" in green) - shown at bottom
1465
+ const formatGreen = (text: string) => (useColor ? green(text) : text);
1466
+ const descriptionLines = [
1467
+ `Use ${formatGreen('Prisma Next')} to define your data layer as a contract. Sign your database and application with the same contract to guarantee compatibility. Plan and apply migrations to safely evolve your schema.`,
1468
+ ];
1469
+ if (descriptionLines.length > 0) {
1470
+ lines.push(formatDimText('│')); // Separator line before description
1471
+ lines.push(...formatMultilineDescription({ descriptionLines, useColor, formatDimText }));
1472
+ }
1473
+
1474
+ lines.push(formatDimText('└'));
1475
+
1476
+ return `${lines.join('\n')}\n`;
1477
+ }