@prisma-next/cli 0.11.0-dev.43 → 0.11.0-dev.45

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 (58) hide show
  1. package/dist/cli.mjs +8 -8
  2. package/dist/{command-helpers-BnqwTptC.mjs → command-helpers-yLuA78TP.mjs} +226 -4
  3. package/dist/command-helpers-yLuA78TP.mjs.map +1 -0
  4. package/dist/commands/contract-emit.mjs +1 -1
  5. package/dist/commands/contract-infer.mjs +1 -1
  6. package/dist/commands/db-init.mjs +3 -3
  7. package/dist/commands/db-schema.mjs +3 -3
  8. package/dist/commands/db-sign.mjs +2 -2
  9. package/dist/commands/db-update.mjs +3 -3
  10. package/dist/commands/db-verify.mjs +1 -1
  11. package/dist/commands/migrate.mjs +2 -2
  12. package/dist/commands/migration-check.mjs +1 -1
  13. package/dist/commands/migration-graph.mjs +1 -1
  14. package/dist/commands/migration-list.d.mts +8 -1
  15. package/dist/commands/migration-list.d.mts.map +1 -1
  16. package/dist/commands/migration-list.mjs +2 -2
  17. package/dist/commands/migration-log.mjs +1 -1
  18. package/dist/commands/migration-new.mjs +1 -1
  19. package/dist/commands/migration-plan.d.mts +1 -1
  20. package/dist/commands/migration-plan.mjs +1 -1
  21. package/dist/commands/migration-show.mjs +2 -2
  22. package/dist/commands/migration-status.mjs +2 -2
  23. package/dist/commands/ref.mjs +1 -1
  24. package/dist/{contract-emit-aFcOi3aw.mjs → contract-emit-FtDVFs2Q.mjs} +2 -2
  25. package/dist/{contract-emit-aFcOi3aw.mjs.map → contract-emit-FtDVFs2Q.mjs.map} +1 -1
  26. package/dist/{contract-infer-BpJeg-Eu.mjs → contract-infer-CVMuoJKk.mjs} +3 -3
  27. package/dist/{contract-infer-BpJeg-Eu.mjs.map → contract-infer-CVMuoJKk.mjs.map} +1 -1
  28. package/dist/{db-verify-CxtdGiL3.mjs → db-verify-B00o3LuC.mjs} +3 -3
  29. package/dist/{db-verify-CxtdGiL3.mjs.map → db-verify-B00o3LuC.mjs.map} +1 -1
  30. package/dist/exports/index.mjs +1 -1
  31. package/dist/exports/init-output.d.mts.map +1 -1
  32. package/dist/{global-flags-CdE7M0d9.d.mts → global-flags-Dvibm2yu.d.mts} +1 -1
  33. package/dist/{global-flags-CdE7M0d9.d.mts.map → global-flags-Dvibm2yu.d.mts.map} +1 -1
  34. package/dist/{init-eGkSo7hi.mjs → init-BKgB6EKw.mjs} +2 -2
  35. package/dist/{init-eGkSo7hi.mjs.map → init-BKgB6EKw.mjs.map} +1 -1
  36. package/dist/{inspect-live-schema-B1GCyjAJ.mjs → inspect-live-schema-BXUd6RfS.mjs} +2 -2
  37. package/dist/{inspect-live-schema-B1GCyjAJ.mjs.map → inspect-live-schema-BXUd6RfS.mjs.map} +1 -1
  38. package/dist/{migration-command-scaffold-CNdZl60X.mjs → migration-command-scaffold-3l3EdmSD.mjs} +2 -2
  39. package/dist/{migration-command-scaffold-CNdZl60X.mjs.map → migration-command-scaffold-3l3EdmSD.mjs.map} +1 -1
  40. package/dist/{migration-list-CnYiHrNV.mjs → migration-list-DopkAG7L.mjs} +40 -49
  41. package/dist/migration-list-DopkAG7L.mjs.map +1 -0
  42. package/dist/migration-list-graph-render-C-daUZLU.d.mts +7 -0
  43. package/dist/migration-list-graph-render-C-daUZLU.d.mts.map +1 -0
  44. package/dist/{migration-plan-ulpJu26J.mjs → migration-plan-BHoeET4O.mjs} +2 -2
  45. package/dist/{migration-plan-ulpJu26J.mjs.map → migration-plan-BHoeET4O.mjs.map} +1 -1
  46. package/dist/{migrations-C7YTBnLy.mjs → migrations-D-UCOGtk.mjs} +2 -2
  47. package/dist/{migrations-C7YTBnLy.mjs.map → migrations-D-UCOGtk.mjs.map} +1 -1
  48. package/dist/{verify-DX4RQwq4.mjs → verify-9gDJz6cm.mjs} +2 -2
  49. package/dist/{verify-DX4RQwq4.mjs.map → verify-9gDJz6cm.mjs.map} +1 -1
  50. package/package.json +18 -18
  51. package/src/commands/migration-list.ts +40 -16
  52. package/src/utils/formatters/migration-list-data-column.ts +84 -0
  53. package/src/utils/formatters/migration-list-graph-render.ts +319 -0
  54. package/src/utils/formatters/migration-list-render.ts +44 -51
  55. package/src/utils/formatters/migration-list-styler.ts +3 -0
  56. package/src/utils/terminal-ui.ts +46 -1
  57. package/dist/command-helpers-BnqwTptC.mjs.map +0 -1
  58. package/dist/migration-list-CnYiHrNV.mjs.map +0 -1
@@ -0,0 +1,319 @@
1
+ import type {
2
+ ConnectorLayoutRow,
3
+ LayoutRow,
4
+ MigrationLayoutRow,
5
+ MigrationListGraphLayout,
6
+ NodeLineLayoutRow,
7
+ } from '@prisma-next/migration-tools/migration-list-graph-layout';
8
+ import { computeMigrationListGraphLayout } from '@prisma-next/migration-tools/migration-list-graph-layout';
9
+ import type { EdgeKind } from '@prisma-next/migration-tools/migration-list-graph-topology';
10
+ import type {
11
+ MigrationListEntry,
12
+ MigrationListResult,
13
+ } from '@prisma-next/migration-tools/migration-list-types';
14
+ import {
15
+ abbreviateContractHash,
16
+ computeMigrationDirNameWidth,
17
+ formatMigrationDataColumn,
18
+ formatNodeLineDataColumn,
19
+ MIGRATION_LIST_EMPTY_SOURCE,
20
+ MIGRATION_LIST_FORWARD_EDGE_GLYPH,
21
+ } from './migration-list-data-column';
22
+ import type { MigrationListStyler } from './migration-list-render';
23
+
24
+ export type GlyphMode = 'unicode' | 'ascii';
25
+
26
+ export interface GlyphModeInput {
27
+ readonly isTTY: boolean;
28
+ readonly env: Readonly<Record<string, string | undefined>>;
29
+ }
30
+
31
+ interface GlyphPalette {
32
+ readonly lane: string;
33
+ readonly node: string;
34
+ readonly forwardArrow: string;
35
+ readonly emptySource: string;
36
+ readonly kind: Record<EdgeKind, string>;
37
+ readonly fanBelow: (branchCount: number) => string;
38
+ readonly joinBelow: (branchCount: number) => string;
39
+ }
40
+
41
+ const UNICODE_PALETTE: GlyphPalette = {
42
+ lane: '│',
43
+ node: 'o',
44
+ forwardArrow: MIGRATION_LIST_FORWARD_EDGE_GLYPH,
45
+ emptySource: MIGRATION_LIST_EMPTY_SOURCE,
46
+ kind: { forward: '*', rollback: '↩', self: '⟲' },
47
+ fanBelow: (branchCount) => (branchCount === 2 ? '├─┐' : '├─┬─┐'),
48
+ joinBelow: (branchCount) => (branchCount === 2 ? '├─┘' : '└─┴─┘'),
49
+ };
50
+
51
+ const ASCII_PALETTE: GlyphPalette = {
52
+ lane: '|',
53
+ node: 'o',
54
+ forwardArrow: '->',
55
+ emptySource: '-',
56
+ kind: { forward: '*', rollback: '<', self: '~' },
57
+ fanBelow: (branchCount) => (branchCount === 2 ? '+-\\' : '+-|-\\'),
58
+ joinBelow: (branchCount) => (branchCount === 2 ? '+-/' : '/-+-/'),
59
+ };
60
+
61
+ function paletteFor(mode: GlyphMode): GlyphPalette {
62
+ return mode === 'ascii' ? ASCII_PALETTE : UNICODE_PALETTE;
63
+ }
64
+
65
+ function localeString(env: Readonly<Record<string, string | undefined>>): string {
66
+ return env['LC_ALL'] ?? env['LC_CTYPE'] ?? env['LANG'] ?? '';
67
+ }
68
+
69
+ function isUtf8Locale(env: Readonly<Record<string, string | undefined>>): boolean {
70
+ const locale = localeString(env);
71
+ if (locale.length === 0) return false;
72
+ return /UTF-8|utf8/i.test(locale);
73
+ }
74
+
75
+ export function detectGlyphMode(input: GlyphModeInput): GlyphMode {
76
+ if (!input.isTTY) return 'ascii';
77
+ if (!isUtf8Locale(input.env)) return 'ascii';
78
+ return 'unicode';
79
+ }
80
+
81
+ function migrationEntries(layout: MigrationListGraphLayout) {
82
+ const entries = [];
83
+ for (const row of layout.rows) {
84
+ if (row.kind === 'migration') entries.push(row.entry);
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ function layoutMaxLaneIndex(layout: MigrationListGraphLayout): number {
90
+ let max = 0;
91
+ for (const row of layout.rows) {
92
+ if (row.kind === 'migration') {
93
+ max = Math.max(max, row.laneIndex, ...row.passThroughLanes);
94
+ } else if (row.kind === 'connector') {
95
+ max = Math.max(max, row.endLane);
96
+ } else {
97
+ max = Math.max(max, row.laneIndex);
98
+ }
99
+ }
100
+ return max;
101
+ }
102
+
103
+ function laneCell(glyph: string): string {
104
+ return `${glyph} `;
105
+ }
106
+
107
+ function emptyLaneCell(): string {
108
+ return ' ';
109
+ }
110
+
111
+ function renderMigrationGutter(
112
+ row: MigrationLayoutRow,
113
+ maxLane: number,
114
+ palette: GlyphPalette,
115
+ style: MigrationListStyler,
116
+ ): string {
117
+ const cells: string[] = [];
118
+ for (let lane = 0; lane <= maxLane; lane++) {
119
+ if (lane === row.laneIndex) {
120
+ cells.push(laneCell(palette.kind[row.edgeKind]));
121
+ } else if (row.passThroughLanes.includes(lane)) {
122
+ cells.push(laneCell(style.lane(palette.lane)));
123
+ } else {
124
+ cells.push(emptyLaneCell());
125
+ }
126
+ }
127
+ return cells.join('');
128
+ }
129
+
130
+ function renderNodeLineGutter(
131
+ row: NodeLineLayoutRow,
132
+ openLanes: ReadonlySet<number>,
133
+ maxLane: number,
134
+ palette: GlyphPalette,
135
+ style: MigrationListStyler,
136
+ ): string {
137
+ const cells: string[] = [];
138
+ for (let lane = 0; lane <= maxLane; lane++) {
139
+ if (lane === row.laneIndex) {
140
+ cells.push(laneCell(palette.node));
141
+ } else if (openLanes.has(lane)) {
142
+ cells.push(laneCell(style.lane(palette.lane)));
143
+ } else {
144
+ cells.push(emptyLaneCell());
145
+ }
146
+ }
147
+ return cells.join('');
148
+ }
149
+
150
+ function renderConnectorGutter(
151
+ row: ConnectorLayoutRow,
152
+ openLanes: ReadonlySet<number>,
153
+ maxLane: number,
154
+ palette: GlyphPalette,
155
+ style: MigrationListStyler,
156
+ ): string {
157
+ const spanLaneCount = row.endLane - row.startLane + 1;
158
+ const spanWidth = spanLaneCount * 2;
159
+ let spanGlyph = (
160
+ row.connectorKind === 'fanBelow'
161
+ ? palette.fanBelow(row.branchCount)
162
+ : palette.joinBelow(row.branchCount)
163
+ )
164
+ .padEnd(spanWidth, ' ')
165
+ .slice(0, spanWidth);
166
+
167
+ const hasOutsideOpen = [...openLanes].some((lane) => lane < row.startLane || lane > row.endLane);
168
+ if (!hasOutsideOpen && spanGlyph.endsWith(' ')) {
169
+ spanGlyph = spanGlyph.slice(0, -1);
170
+ }
171
+
172
+ let gutter = '';
173
+ for (let lane = 0; lane < row.startLane; lane++) {
174
+ gutter += openLanes.has(lane) ? laneCell(style.lane(palette.lane)) : emptyLaneCell();
175
+ }
176
+ gutter += style.lane(spanGlyph);
177
+ for (let lane = row.endLane + 1; lane <= maxLane; lane++) {
178
+ gutter += openLanes.has(lane) ? laneCell(style.lane(palette.lane)) : emptyLaneCell();
179
+ }
180
+ return gutter;
181
+ }
182
+
183
+ function advanceOpenLanes(row: LayoutRow, openLanes: ReadonlySet<number>): ReadonlySet<number> {
184
+ if (row.kind === 'migration') {
185
+ return new Set([row.laneIndex, ...row.passThroughLanes]);
186
+ }
187
+ if (row.kind === 'connector') {
188
+ if (row.connectorKind === 'fanBelow') {
189
+ const next = new Set(openLanes);
190
+ for (let lane = row.startLane; lane <= row.endLane; lane++) {
191
+ next.add(lane);
192
+ }
193
+ return next;
194
+ }
195
+ const next = new Set(openLanes);
196
+ for (let lane = row.startLane + 1; lane <= row.endLane; lane++) {
197
+ next.delete(lane);
198
+ }
199
+ return next;
200
+ }
201
+ return openLanes;
202
+ }
203
+
204
+ export function renderMigrationListGraphWithStyle(
205
+ layout: MigrationListGraphLayout,
206
+ style: MigrationListStyler,
207
+ glyphMode: GlyphMode,
208
+ ): string {
209
+ const palette = paletteFor(glyphMode);
210
+ const migrations = migrationEntries(layout);
211
+ const layoutMaxLane = layoutMaxLaneIndex(layout);
212
+ const dirNameWidth = computeMigrationDirNameWidth(migrations);
213
+ const gutterMaxLane = layoutMaxLane;
214
+ const blockDataStart = (layoutMaxLane + 1) * 2;
215
+ // Migration and node-line gutters always occupy a fixed, ANSI-free visible
216
+ // width of two columns per lane. Padding is computed from this width rather
217
+ // than the rendered string length so dimmed lanes (which carry zero-width
218
+ // SGR bytes) stay column-aligned with the data that follows.
219
+ const gutterVisibleWidth = (gutterMaxLane + 1) * 2;
220
+ const lines: string[] = [];
221
+ let openLanes: ReadonlySet<number> = new Set();
222
+
223
+ function padToDataColumn(gutter: string, dataStart: number): string {
224
+ return gutter + ' '.repeat(Math.max(0, dataStart - gutterVisibleWidth));
225
+ }
226
+
227
+ for (const row of layout.rows) {
228
+ if (row.kind === 'migration') {
229
+ const gutter = renderMigrationGutter(row, gutterMaxLane, palette, style);
230
+ const data = formatMigrationDataColumn(row.entry, {
231
+ dirNameWidth,
232
+ edgeKind: row.edgeKind,
233
+ style,
234
+ forwardArrow: palette.forwardArrow,
235
+ emptySource: palette.emptySource,
236
+ });
237
+ lines.push(`${padToDataColumn(gutter, blockDataStart)}${data}`);
238
+ } else if (row.kind === 'nodeLine') {
239
+ const gutter = renderNodeLineGutter(row, openLanes, gutterMaxLane, palette, style);
240
+ const data = formatNodeLineDataColumn(row.contractHash, style);
241
+ lines.push(`${padToDataColumn(gutter, blockDataStart)}${data}`);
242
+ } else {
243
+ lines.push(renderConnectorGutter(row, openLanes, gutterMaxLane, palette, style));
244
+ }
245
+ openLanes = advanceOpenLanes(row, openLanes);
246
+ }
247
+
248
+ return lines.join('\n');
249
+ }
250
+
251
+ export function renderMigrationListGraph(
252
+ layout: MigrationListGraphLayout,
253
+ style: MigrationListStyler,
254
+ glyphMode: GlyphMode,
255
+ ): string {
256
+ return renderMigrationListGraphWithStyle(layout, style, glyphMode);
257
+ }
258
+
259
+ export function formatGraphNodeLineHash(contractHash: string, style: MigrationListStyler): string {
260
+ return style.sourceHash(abbreviateContractHash(contractHash));
261
+ }
262
+
263
+ function formatGraphEmptyStateLine(spaceId: string, style: MigrationListStyler): string {
264
+ return style.emptyState(`There are no migrations in migrations/${spaceId}/ yet`);
265
+ }
266
+
267
+ function renderGraphSpaceBlock(
268
+ spaceId: string,
269
+ migrations: readonly MigrationListEntry[],
270
+ multiSpace: boolean,
271
+ style: MigrationListStyler,
272
+ glyphMode: GlyphMode,
273
+ ): readonly string[] {
274
+ if (migrations.length === 0) {
275
+ const emptyLine = formatGraphEmptyStateLine(spaceId, style);
276
+ if (!multiSpace) {
277
+ return [emptyLine];
278
+ }
279
+ return [style.spaceHeading(`${spaceId}:`), ` ${emptyLine}`];
280
+ }
281
+
282
+ const layout = computeMigrationListGraphLayout(migrations);
283
+ const graphBody = renderMigrationListGraphWithStyle(layout, style, glyphMode);
284
+ const rows = graphBody.split('\n');
285
+ if (!multiSpace) {
286
+ return rows;
287
+ }
288
+ return [style.spaceHeading(`${spaceId}:`), ...rows.map((row) => ` ${row}`)];
289
+ }
290
+
291
+ export function renderMigrationListGraphResult(
292
+ result: MigrationListResult,
293
+ style: MigrationListStyler,
294
+ glyphMode: GlyphMode,
295
+ ): string {
296
+ const multiSpace = result.spaces.length > 1;
297
+ const lines: string[] = [];
298
+
299
+ for (let index = 0; index < result.spaces.length; index++) {
300
+ const space = result.spaces[index]!;
301
+ if (index > 0) {
302
+ lines.push('');
303
+ }
304
+ lines.push(
305
+ ...renderGraphSpaceBlock(space.spaceId, space.migrations, multiSpace, style, glyphMode),
306
+ );
307
+ }
308
+
309
+ const totalMigrations = result.spaces.reduce(
310
+ (count, space) => count + space.migrations.length,
311
+ 0,
312
+ );
313
+ if (totalMigrations > 0) {
314
+ lines.push('');
315
+ lines.push(style.summary(result.summary));
316
+ }
317
+
318
+ return lines.join('\n');
319
+ }
@@ -1,7 +1,18 @@
1
+ import {
2
+ classifyMigrationListGraphTopology,
3
+ type EdgeKind,
4
+ } from '@prisma-next/migration-tools/migration-list-graph-topology';
1
5
  import type {
2
6
  MigrationListEntry,
3
7
  MigrationListResult,
4
8
  } from '@prisma-next/migration-tools/migration-list-types';
9
+ import {
10
+ computeMigrationDirNameWidth,
11
+ formatMigrationDataColumn,
12
+ MIGRATION_LIST_FORWARD_EDGE_GLYPH,
13
+ } from './migration-list-data-column';
14
+
15
+ export type { EdgeKind } from '@prisma-next/migration-tools/migration-list-graph-topology';
5
16
 
6
17
  export type {
7
18
  MigrationListEntry,
@@ -9,11 +20,11 @@ export type {
9
20
  MigrationSpaceListEntry,
10
21
  } from '@prisma-next/migration-tools/migration-list-types';
11
22
 
12
- const HASH_WIDTH = 7;
13
- const EMPTY_SOURCE = '';
14
- const SELF_EDGE_GLYPH = '';
15
- const FORWARD_EDGE_GLYPH = '';
16
- const DECORATION_PREFIX = ' ';
23
+ const KIND_GLYPH: Record<EdgeKind, string> = {
24
+ forward: '*',
25
+ rollback: '',
26
+ self: '',
27
+ };
17
28
 
18
29
  /**
19
30
  * Semantic styler for `migration list` output tokens. Token-typed so
@@ -29,10 +40,12 @@ const DECORATION_PREFIX = ' ';
29
40
  * having to re-parse a joined block.
30
41
  */
31
42
  export interface MigrationListStyler {
43
+ kind(text: string): string;
32
44
  dirName(text: string): string;
33
45
  sourceHash(text: string): string;
34
46
  destHash(text: string): string;
35
47
  glyph(text: string): string;
48
+ lane(text: string): string;
36
49
  invariants(ids: readonly string[]): string;
37
50
  refs(names: readonly string[]): string;
38
51
  spaceHeading(text: string): string;
@@ -41,10 +54,12 @@ export interface MigrationListStyler {
41
54
  }
42
55
 
43
56
  export const IDENTITY_MIGRATION_LIST_STYLER: MigrationListStyler = {
57
+ kind: (text) => text,
44
58
  dirName: (text) => text,
45
59
  sourceHash: (text) => text,
46
60
  destHash: (text) => text,
47
61
  glyph: (text) => text,
62
+ lane: (text) => text,
48
63
  invariants: (ids) => `{${ids.join(', ')}}`,
49
64
  refs: (names) => `(${names.join(', ')})`,
50
65
  spaceHeading: (text) => text,
@@ -52,57 +67,27 @@ export const IDENTITY_MIGRATION_LIST_STYLER: MigrationListStyler = {
52
67
  emptyState: (text) => text,
53
68
  };
54
69
 
55
- function abbreviateContractHash(hash: string): string {
56
- const stripped = hash.startsWith('sha256:') ? hash.slice(7) : hash;
57
- return stripped.slice(0, HASH_WIDTH);
58
- }
59
-
60
- function formatSourceColumn(from: string | null, style: MigrationListStyler): string {
61
- if (from === null) {
62
- return style.glyph(EMPTY_SOURCE) + ' '.repeat(HASH_WIDTH - EMPTY_SOURCE.length);
63
- }
64
- return style.sourceHash(abbreviateContractHash(from));
65
- }
66
-
67
- function formatDestColumn(from: string | null, to: string, style: MigrationListStyler): string {
68
- if (from !== null && from === to) {
69
- return ' '.repeat(HASH_WIDTH);
70
- }
71
- return style.destHash(abbreviateContractHash(to));
72
- }
73
-
74
- function formatArrowGlyph(from: string | null, to: string, style: MigrationListStyler): string {
75
- return style.glyph(from !== null && from === to ? SELF_EDGE_GLYPH : FORWARD_EDGE_GLYPH);
76
- }
77
-
78
- function formatDecorations(
79
- providedInvariants: readonly string[],
80
- refs: readonly string[],
81
- style: MigrationListStyler,
82
- ): string {
83
- const blocks: string[] = [];
84
- if (providedInvariants.length > 0) {
85
- blocks.push(style.invariants(providedInvariants));
86
- }
87
- if (refs.length > 0) {
88
- blocks.push(style.refs(refs));
89
- }
90
- if (blocks.length === 0) return '';
91
- return `${DECORATION_PREFIX}${blocks.join(' ')}`;
70
+ function resolveEdgeKind(
71
+ migrationHash: string,
72
+ kindByMigrationHash: ReadonlyMap<string, EdgeKind>,
73
+ ): EdgeKind {
74
+ return kindByMigrationHash.get(migrationHash) ?? 'forward';
92
75
  }
93
76
 
94
77
  function formatMigrationRow(
95
78
  migration: MigrationListEntry,
96
79
  dirNameWidth: number,
80
+ edgeKind: EdgeKind,
97
81
  style: MigrationListStyler,
98
82
  ): string {
99
- const dirNamePadding = ' '.repeat(Math.max(0, dirNameWidth - migration.dirName.length));
100
- const dirName = `${style.dirName(migration.dirName)}${dirNamePadding}`;
101
- const source = formatSourceColumn(migration.from, style);
102
- const arrow = formatArrowGlyph(migration.from, migration.to, style);
103
- const dest = formatDestColumn(migration.from, migration.to, style);
104
- const decorations = formatDecorations(migration.providedInvariants, migration.refs, style);
105
- return `${dirName}${source} ${arrow} ${dest}${decorations}`;
83
+ const kindColumn = `${style.kind(KIND_GLYPH[edgeKind])} `;
84
+ const data = formatMigrationDataColumn(migration, {
85
+ dirNameWidth,
86
+ edgeKind,
87
+ style,
88
+ forwardArrow: MIGRATION_LIST_FORWARD_EDGE_GLYPH,
89
+ });
90
+ return `${kindColumn}${data}`;
106
91
  }
107
92
 
108
93
  function formatEmptyStateLine(spaceId: string, style: MigrationListStyler): string {
@@ -123,8 +108,16 @@ function renderSpaceBlock(
123
108
  return [style.spaceHeading(`${spaceId}:`), ` ${emptyLine}`];
124
109
  }
125
110
 
126
- const dirNameWidth = Math.max(...migrations.map((entry) => entry.dirName.length)) + 2;
127
- const rows = migrations.map((entry) => formatMigrationRow(entry, dirNameWidth, style));
111
+ const kindByMigrationHash = classifyMigrationListGraphTopology(migrations).kindByMigrationHash;
112
+ const dirNameWidth = computeMigrationDirNameWidth(migrations);
113
+ const rows = migrations.map((entry) =>
114
+ formatMigrationRow(
115
+ entry,
116
+ dirNameWidth,
117
+ resolveEdgeKind(entry.migrationHash, kindByMigrationHash),
118
+ style,
119
+ ),
120
+ );
128
121
  if (!multiSpace) {
129
122
  return rows;
130
123
  }
@@ -25,6 +25,7 @@ function styleRefName(name: string): string {
25
25
  * - `sourceHash`: dim cyan
26
26
  * - `destHash`: bright cyan
27
27
  * - `glyph` (`→` / `⟲` / `∅`): dim
28
+ * - `lane` (graph gutter lines `│` and fan/join connectors `├─┐` / `├─┘`): dim
28
29
  * - `invariants` (`{...}`): yellow
29
30
  * - `refs` (`(...)`): green; the live-DB `db` marker inside is green-bold
30
31
  * - `spaceHeading` (`<spaceId>:`): bold
@@ -38,10 +39,12 @@ export function createAnsiMigrationListStyler(opts: {
38
39
  return IDENTITY_MIGRATION_LIST_STYLER;
39
40
  }
40
41
  return {
42
+ kind: (text) => dim(text),
41
43
  dirName: (text) => bold(text),
42
44
  sourceHash: (text) => dim(cyan(text)),
43
45
  destHash: (text) => cyanBright(text),
44
46
  glyph: (text) => dim(text),
47
+ lane: (text) => dim(text),
45
48
  invariants: (ids) => yellow(`{${ids.join(', ')}}`),
46
49
  refs: (names) => {
47
50
  const open = green('(');
@@ -1,8 +1,15 @@
1
1
  import * as clack from '@clack/prompts';
2
2
  import { bold, cyan, dim, green, red, yellow } from 'colorette';
3
+ import {
4
+ detectGlyphMode,
5
+ type GlyphMode,
6
+ type GlyphModeInput,
7
+ } from './formatters/migration-list-graph-render';
3
8
  import type { GlobalFlags } from './global-flags';
4
9
  import { shutdownSignal } from './shutdown';
5
10
 
11
+ export interface TerminalUIRuntime extends GlyphModeInput {}
12
+
6
13
  /**
7
14
  * Composable CLI output abstraction.
8
15
  *
@@ -36,17 +43,50 @@ export class TerminalUI {
36
43
  */
37
44
  readonly forcePretty: boolean;
38
45
 
46
+ /**
47
+ * Whether stdout is a TTY — used for migration-list graph glyph detection.
48
+ */
49
+ readonly stdoutIsTTY: boolean;
50
+
51
+ /**
52
+ * Process environment snapshot for locale-aware glyph detection.
53
+ */
54
+ readonly env: Readonly<Record<string, string | undefined>>;
55
+
39
56
  private static readonly stderrOpts = { output: process.stderr } as const;
40
57
 
41
58
  constructor(options?: {
42
59
  readonly color?: boolean | undefined;
43
60
  readonly interactive?: boolean | undefined;
44
61
  readonly forcePretty?: boolean | undefined;
62
+ readonly stdoutIsTTY?: boolean | undefined;
63
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
45
64
  }) {
46
65
  // --interactive/--no-interactive override TTY detection
47
66
  this.isInteractive = options?.interactive ?? !!process.stdout.isTTY;
48
67
  this.forcePretty = options?.forcePretty ?? false;
49
68
  this.useColor = options?.color ?? (this.isInteractive || this.forcePretty);
69
+ this.stdoutIsTTY = options?.stdoutIsTTY ?? !!process.stdout.isTTY;
70
+ this.env = options?.env ?? process.env;
71
+ }
72
+
73
+ get isTTY(): boolean {
74
+ return this.stdoutIsTTY;
75
+ }
76
+
77
+ /**
78
+ * Resolve graph glyph mode for `migration list --graph`. `--ascii` forces
79
+ * ASCII; otherwise delegates to the pure {@link detectGlyphMode} helper.
80
+ */
81
+ resolveGlyphMode(forceAscii: boolean): GlyphMode {
82
+ if (forceAscii) {
83
+ return 'ascii';
84
+ }
85
+ return detectGlyphMode(this.glyphModeInput());
86
+ }
87
+
88
+ glyphModeInput(): GlyphModeInput {
89
+ return { isTTY: this.stdoutIsTTY, env: this.env };
50
90
  }
51
91
 
52
92
  private get shouldDecorate(): boolean {
@@ -288,10 +328,15 @@ export class TerminalUI {
288
328
  }
289
329
  }
290
330
 
291
- export function createTerminalUI(flags: GlobalFlags): TerminalUI {
331
+ export function createTerminalUI(
332
+ flags: GlobalFlags,
333
+ runtime?: Partial<TerminalUIRuntime>,
334
+ ): TerminalUI {
292
335
  return new TerminalUI({
293
336
  color: flags.color,
294
337
  interactive: flags.interactive,
295
338
  forcePretty: flags.format === 'pretty' && flags.explicitFormat,
339
+ stdoutIsTTY: runtime?.isTTY,
340
+ env: runtime?.env,
296
341
  });
297
342
  }