@dialpad/dialtone-css 8.80.0-next.7 → 8.80.0-next.9

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.
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable max-lines */
3
+ /* eslint-disable complexity */
4
+
5
+ /**
6
+ * @fileoverview Master migration script for Dialtone next major version.
7
+ *
8
+ * Orchestrates all individual migration scripts in the correct order.
9
+ * Supports selective execution, dry-run mode, and a health-check to
10
+ * report which migrations are still needed.
11
+ *
12
+ * Usage:
13
+ * npx dialtone-migrate [options]
14
+ *
15
+ * Options:
16
+ * --cwd <path> Working directory (default: cwd)
17
+ * --dry-run Show changes without applying them
18
+ * --yes Apply all changes without prompting
19
+ * --health-check Report migration status without modifying files
20
+ * --all Run all required migrations without selection prompt
21
+ * --only <ids> Comma-separated list of migration IDs to run
22
+ * --help Show help
23
+ *
24
+ * Examples:
25
+ * npx dialtone-migrate
26
+ * npx dialtone-migrate --health-check --cwd ./src
27
+ * npx dialtone-migrate --all --dry-run
28
+ * npx dialtone-migrate --only color-stops,hsl-to-oklch
29
+ */
30
+
31
+ import { spawn } from 'node:child_process';
32
+ import fs from 'fs/promises';
33
+ import path from 'path';
34
+ import readline from 'readline';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const __filename = fileURLToPath(import.meta.url);
38
+ const __dirname = path.dirname(__filename);
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Migration registry
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * @typedef {object} Migration
46
+ * @property {string} id - Unique identifier
47
+ * @property {string} name - Display name
48
+ * @property {string} description - Short description
49
+ * @property {'required'|'opt-in'} category
50
+ * @property {'config'|'standalone'|'manual'} type
51
+ * @property {string} [configName] - Config filename for migration-helper type
52
+ * @property {string} [scriptDir] - Directory name for standalone scripts
53
+ * @property {string[]} [extraArgs] - Extra CLI args to forward
54
+ * @property {RegExp[]} detectPatterns - Patterns that indicate migration is still needed
55
+ * @property {string[]} fileExtensions - File extensions to scan during health check
56
+ */
57
+
58
+ /** @type {Migration[]} */
59
+ const MIGRATIONS = [
60
+ // ── Required (breaking) ────────────────────────────────────────────
61
+ {
62
+ id: 'color-stops',
63
+ name: 'Color Stops',
64
+ description: 'Base color ramps standardized to a 12-stop scale.',
65
+ category: 'required',
66
+ type: 'config',
67
+ configName: 'color-stops',
68
+ detectPatterns: [
69
+ /var\(--dt-color-(?:purple|magenta)-(?:250|350)\)/,
70
+ /var\(--dt-color-(?:blue|green|red|gold)-(?:425|450|475)\)/,
71
+ /d-(?:bgc|fc|bc)-(?:purple|magenta)-(?:250|350)/,
72
+ ],
73
+ fileExtensions: ['.css', '.less', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
74
+ },
75
+ {
76
+ id: 'hsl-to-oklch',
77
+ name: 'HSL to OKLCH',
78
+ description: 'Color tokens moved from HSL to OKLCH. Per-channel breakout variables removed.',
79
+ category: 'required',
80
+ type: 'config',
81
+ configName: 'hsl-to-oklch',
82
+ detectPatterns: [
83
+ /var\(--[\w-]+-(?:hsl|hsla)\)/,
84
+ /var\(--[\w-]+-h\)\s*,\s*var\(--[\w-]+-s\)/,
85
+ ],
86
+ fileExtensions: ['.css', '.less', '.scss', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
87
+ },
88
+ {
89
+ id: 'space-to-spacing',
90
+ name: 'Space to Spacing Tokens',
91
+ description: '--dt-space-* becomes --dt-spacing-*.',
92
+ category: 'required',
93
+ type: 'config',
94
+ configName: 'space-to-spacing',
95
+ detectPatterns: [
96
+ /var\(--dt-space-\d+/,
97
+ ],
98
+ fileExtensions: ['.css', '.less', '.scss', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
99
+ },
100
+ {
101
+ id: 'size-to-layout',
102
+ name: 'Size to Layout Tokens',
103
+ description: '--dt-size-* routed to --dt-layout-*, --dt-spacing-*, --dt-size-border-*, or --dt-size-radius-*.',
104
+ category: 'required',
105
+ type: 'config',
106
+ configName: 'size-to-layout',
107
+ detectPatterns: [
108
+ /var\(--dt-size-\d+\)/,
109
+ ],
110
+ fileExtensions: ['.css', '.less', '.scss', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
111
+ },
112
+ {
113
+ id: 'border-radius',
114
+ name: 'Border-Radius Logical Names',
115
+ description: 'Physical directional radius classes (d-btr*, d-bbr*, d-blr*, d-brr*) replaced by logical equivalents (d-bbsr*, d-bber*, d-bisr*, d-bier*). Numeric stops standardized.',
116
+ category: 'required',
117
+ type: 'standalone',
118
+ scriptDir: 'dialtone_migrate_border_radius',
119
+ detectPatterns: [
120
+ /(?:["'\s=`])d-bar(?:0|1|2|4|6|8|12|16|24|32)(?:["'\s>;`])/,
121
+ /(?:["'\s=`])d-(?:btr|bbr|blr|brr)(?:\d|-pill|-circle)(?:["'\s>;`])/,
122
+ ],
123
+ fileExtensions: ['.vue', '.html', '.js', '.ts', '.jsx', '.tsx', '.md'],
124
+ },
125
+ {
126
+ id: 'utility-class-to-token-stops',
127
+ name: 'Utility Class Token Stops',
128
+ description: 'Pixel-based utility class names migrated to token-stop-based names (d-h16 → d-h-25, d-p8 → d-p-100, etc.).',
129
+ category: 'required',
130
+ type: 'config',
131
+ configName: 'utility-class-to-token-stops',
132
+ detectPatterns: [
133
+ /(?:["'\s])d-(?:h|w|hmn|hmx|wmn|wmx)(?:16|32|48|64|96|128)(?:["'\s])/,
134
+ /(?:["'\s])d-(?:m|mt|mr|mb|ml|mx|my|p|pt|pr|pb|pl|px|py)(?:4|8|12|16|24|32|48|64)(?:["'\s])/,
135
+ /(?:["'\s])d-(?:g|rg|cg)(?:4|8|12|16|24|32)(?:["'\s])/,
136
+ ],
137
+ fileExtensions: ['.vue', '.html', '.js', '.ts', '.jsx', '.tsx', '.md', '.css', '.less'],
138
+ },
139
+ {
140
+ id: 'theme-to-mode',
141
+ name: 'Theme to Mode',
142
+ description: 'Deprecated setTheme() and data-dt-theme migrated to the layered theming API (setMode/setBrand/setContrast/initDialtoneTheme).',
143
+ category: 'required',
144
+ type: 'config',
145
+ configName: 'theme-to-mode',
146
+ detectPatterns: [
147
+ /(?<!\.)setTheme\s*\(/,
148
+ /\bdata-dt-theme\b/,
149
+ ],
150
+ fileExtensions: ['.vue', '.html', '.js', '.ts', '.jsx', '.tsx', '.css', '.less', '.scss', '.mjs'],
151
+ },
152
+ {
153
+ id: 'component-props',
154
+ name: 'Component Props & Events',
155
+ description: 'Value renames, show→open, hide-* inversion, title→header-text, event/slot renames, rootClass removal.',
156
+ category: 'required',
157
+ type: 'standalone',
158
+ scriptDir: 'dialtone_migrate_props',
159
+ detectPatterns: [
160
+ /(?:show|hide-close|hide-icon|label-visible|selected-values)(?:=|[\s>])/,
161
+ /<(?:dt-(?:banner|notice|toast|modal)|Dt(?:Banner|Notice|Toast|Modal))\b[^>]*\btitle(?:=|[\s>])/,
162
+ /<(?:dt-[\w-]+|Dt\w+)\b[^>]*@(?:input|change)(?:=|\.)/,
163
+ /kind="(?:danger|error)"/,
164
+ /validation-state="(?:error|success)"/,
165
+ ],
166
+ fileExtensions: ['.vue'],
167
+ },
168
+ {
169
+ id: 'chip-interactive',
170
+ name: 'DtChip Interactive Default',
171
+ description: 'interactive prop default changed from true to false. Clickable chips must opt in.',
172
+ category: 'required',
173
+ type: 'standalone',
174
+ scriptDir: 'dialtone_migrate_chip_interactive',
175
+ detectPatterns: [
176
+ /<(?:dt-chip|DtChip)\b[^>]*(?:@click|v-on:click)[^>]*>/,
177
+ ],
178
+ fileExtensions: ['.vue'],
179
+ },
180
+ {
181
+ id: 'scrollbar-always',
182
+ name: 'Scrollbar :never → :always',
183
+ description: 'v-dt-scrollbar:never renamed to v-dt-scrollbar:always.',
184
+ category: 'required',
185
+ type: 'standalone',
186
+ scriptDir: 'dialtone_migrate_scrollbar_always',
187
+ detectPatterns: [
188
+ /v-dt-scrollbar:never/,
189
+ /scrollbar="never"/,
190
+ ],
191
+ fileExtensions: ['.vue', '.html'],
192
+ },
193
+
194
+ // ── Opt-in (deprecation, best practices) ──────────────────────────
195
+ {
196
+ id: 'base-to-semantic',
197
+ name: 'Base to Semantic Colors',
198
+ description: 'Upgrade base color utilities/tokens to theme-aware semantic equivalents.',
199
+ category: 'opt-in',
200
+ type: 'config',
201
+ configName: 'base-to-semantic',
202
+ detectPatterns: [
203
+ /d-fc-(?:black|red|green|gold)-\d+/,
204
+ /d-bgc-(?:black|red|green|gold|blue|purple)-\d+/,
205
+ ],
206
+ fileExtensions: ['.css', '.less', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
207
+ },
208
+ {
209
+ id: 'success-to-positive',
210
+ name: 'Success to Positive',
211
+ description: 'success* tokens and utility classes deprecated in favor of positive*.',
212
+ category: 'opt-in',
213
+ type: 'config',
214
+ configName: 'success-to-positive',
215
+ detectPatterns: [
216
+ /var\(--dt-color-(?:foreground|surface|border|link)-success/,
217
+ /d-(?:fc|bgc|bc)-success/,
218
+ ],
219
+ fileExtensions: ['.css', '.less', '.html', '.vue', '.js', '.ts', '.jsx', '.tsx'],
220
+ },
221
+ {
222
+ id: 'stack-gap-to-spacing',
223
+ name: 'Stack Gap to Spacing',
224
+ description: 'DtStack and DtDescriptionList gap prop values migrated from old size stops to new spacing stops.',
225
+ category: 'opt-in',
226
+ type: 'config',
227
+ configName: 'stack-gap-to-spacing',
228
+ detectPatterns: [
229
+ /gap="(?:50|100|200|300|350|400|450|500|525|550|600|625|650|700)"/,
230
+ /d-stack--gap-(?:50|100|200|300|350|400|450|500|525|550|600|625|650|700)/,
231
+ ],
232
+ fileExtensions: ['.vue', '.html', '.js', '.ts', '.jsx', '.tsx', '.md'],
233
+ },
234
+ {
235
+ id: 'flex-to-stack',
236
+ name: 'Flex to DtStack',
237
+ description: 'Replace d-d-flex utilities with the <dt-stack> component.',
238
+ category: 'opt-in',
239
+ type: 'standalone',
240
+ scriptDir: 'dialtone_migrate_flex_to_stack',
241
+ detectPatterns: [
242
+ /class="[^"]*d-d-flex[^"]*"/,
243
+ ],
244
+ fileExtensions: ['.vue'],
245
+ },
246
+ {
247
+ id: 'link-rendering',
248
+ name: 'Link and Button Navigation',
249
+ description: 'DtButton/DtLink gain to/href props; legacy anchor/router-link workarounds replaced.',
250
+ category: 'opt-in',
251
+ type: 'standalone',
252
+ scriptDir: 'dialtone_migrate_link_rendering',
253
+ detectPatterns: [
254
+ /<a\b[^>]*class="[^"]*d-btn/,
255
+ /<router-link\b[^>]*class="[^"]*d-link/,
256
+ /<dt-link\b[^>]*class="[^"]*d-td-/,
257
+ ],
258
+ fileExtensions: ['.vue'],
259
+ },
260
+ {
261
+ id: 'tshirt-to-numeric',
262
+ name: 'Component Sizes to Numeric',
263
+ description: 'size="sm" becomes :size="200" across all components.',
264
+ category: 'opt-in',
265
+ type: 'standalone',
266
+ scriptDir: 'dialtone_migrate_tshirt_to_numeric',
267
+ detectPatterns: [
268
+ /<(?:dt-[\w-]+|Dt\w+)\b[^>]*\bsize="(?:xs|sm|md|lg|xl|2xl|3xl)"/,
269
+ ],
270
+ fileExtensions: ['.vue', '.html', '.md'],
271
+ },
272
+ {
273
+ id: 'physical-to-logical',
274
+ name: 'Logical Naming',
275
+ description: 'Slots, props, events: left/right becomes start/end.',
276
+ category: 'opt-in',
277
+ type: 'config',
278
+ configName: 'physical-to-logical',
279
+ detectPatterns: [
280
+ /#leftIcon|#rightIcon|#alphaIcon|#omegaIcon/,
281
+ /(<(?:dt-(?:item-layout|list-item)|Dt(?:ItemLayout|ListItem))[\s\S]*?)#(?:left|right|bottom)(?=[\s"'>])/,
282
+ /alpha-(?:disabled|loading|active)/,
283
+ /omega-(?:disabled|active)/,
284
+ /icon-position="(?:left|right)"/,
285
+ ],
286
+ fileExtensions: ['.vue', '.html'],
287
+ },
288
+ {
289
+ id: 'typography',
290
+ name: 'Typography Utilities to DtText',
291
+ description: 'Replace legacy typography utility classes (d-headline--*, d-body--*, d-label--*, d-code--md, d-fw-*, d-fc-*, d-lh-*, d-truncate, d-ta-*) with the <dt-text> component.',
292
+ category: 'opt-in',
293
+ type: 'standalone',
294
+ scriptDir: 'dialtone_migrate_typography',
295
+ detectPatterns: [
296
+ /class="[^"]*d-(?:headline|body|label)--(?:sm|md|lg|xl|xxl)/,
297
+ /class="[^"]*d-(?:headline|body|label)-(?:small|medium|large)/,
298
+ /class="[^"]*d-code--(?:sm|md|lg)/,
299
+ ],
300
+ fileExtensions: ['.vue', '.html'],
301
+ },
302
+ {
303
+ id: 'vue3-to-vue-imports',
304
+ name: 'Vue 3 Import Paths',
305
+ description: '@dialpad/dialtone-icons/vue3 and @dialpad/dialtone-vue/vue3 import paths renamed to /vue.',
306
+ category: 'required',
307
+ type: 'config',
308
+ configName: 'vue3-to-vue-imports',
309
+ detectPatterns: [
310
+ /@dialpad\/dialtone-icons\/vue3/,
311
+ /@dialpad\/dialtone-vue\/vue3/,
312
+ ],
313
+ fileExtensions: ['.vue', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.mts'],
314
+ },
315
+ ];
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // CLI parsing
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function parseArgs (args) {
322
+ const cwdIndex = args.indexOf('--cwd');
323
+ const onlyIndex = args.indexOf('--only');
324
+ return {
325
+ help: args.includes('--help'),
326
+ dryRun: args.includes('--dry-run'),
327
+ autoYes: args.includes('--yes'),
328
+ healthCheck: args.includes('--health-check'),
329
+ all: args.includes('--all'),
330
+ cwd: cwdIndex !== -1 && args[cwdIndex + 1]
331
+ ? path.resolve(args[cwdIndex + 1])
332
+ : process.cwd(),
333
+ only: onlyIndex !== -1 && args[onlyIndex + 1]
334
+ ? args[onlyIndex + 1].split(',').map(s => s.trim())
335
+ : null,
336
+ };
337
+ }
338
+
339
+ function printHelp () {
340
+ console.log(`
341
+ Usage: npx dialtone-migrate [options]
342
+
343
+ Master migration script for Dialtone next major version.
344
+ Orchestrates all individual migration tools in the correct order.
345
+
346
+ Options:
347
+ --cwd <path> Working directory (default: cwd)
348
+ --dry-run Show changes without applying them
349
+ --yes Apply all changes without prompting
350
+ --health-check Report migration status without modifying files
351
+ --all Run all required migrations without selection prompt
352
+ --only <ids> Comma-separated list of migration IDs to run
353
+ --help Show help
354
+
355
+ Available migration IDs (required):
356
+ ${MIGRATIONS.filter(m => m.category === 'required').map(m => ` ${m.id.padEnd(32)} ${m.name}`).join('\n')}
357
+
358
+ Available migration IDs (opt-in):
359
+ ${MIGRATIONS.filter(m => m.category === 'opt-in').map(m => ` ${m.id.padEnd(32)} ${m.name}`).join('\n')}
360
+
361
+ Examples:
362
+ npx dialtone-migrate # Interactive selection
363
+ npx dialtone-migrate --health-check --cwd ./src # Check migration status
364
+ npx dialtone-migrate --all --dry-run # Dry-run all required
365
+ npx dialtone-migrate --only color-stops,hsl-to-oklch --yes
366
+ `);
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // File walker (shared utility)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ const DEFAULT_IGNORE = ['node_modules', 'dist', '.git', '.vuepress/public', '.vuepress/.temp', '.vuepress/.cache', 'storybook-static'];
374
+
375
+ function isIgnoredPath (fullPath) {
376
+ const segments = fullPath.split(path.sep);
377
+ return DEFAULT_IGNORE.some(ig => {
378
+ if (ig.includes('/')) {
379
+ const parts = ig.split('/');
380
+ for (let i = 0; i + parts.length <= segments.length; i++) {
381
+ if (parts.every((p, j) => segments[i + j] === p)) return true;
382
+ }
383
+ return false;
384
+ }
385
+ return segments.includes(ig);
386
+ });
387
+ }
388
+
389
+ async function findFiles (dir, extensions) {
390
+ const results = [];
391
+ async function walk (currentDir) {
392
+ let entries;
393
+ try {
394
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
395
+ } catch { return; }
396
+ for (const entry of entries) {
397
+ const fullPath = path.join(currentDir, entry.name);
398
+ if (isIgnoredPath(fullPath)) continue;
399
+ if (entry.isDirectory()) {
400
+ await walk(fullPath);
401
+ } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
402
+ results.push(fullPath);
403
+ }
404
+ }
405
+ }
406
+ await walk(dir);
407
+ return results;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Health check
412
+ // ---------------------------------------------------------------------------
413
+
414
+ async function healthCheck (cwd) {
415
+ console.log(`\n${'━'.repeat(70)}`);
416
+ console.log(' DIALTONE MIGRATION HEALTH CHECK');
417
+ console.log(`${'━'.repeat(70)}\n`);
418
+ console.log(` Scanning: ${cwd}\n`);
419
+
420
+ // Collect all needed extensions
421
+ const allExtensions = [...new Set(MIGRATIONS.flatMap(m => m.fileExtensions))];
422
+ const files = await findFiles(cwd, allExtensions);
423
+
424
+ if (files.length === 0) {
425
+ console.log(' No scannable files found in the target directory.\n');
426
+ return;
427
+ }
428
+
429
+ // Read all files once
430
+ const fileContents = new Map();
431
+ for (const file of files) {
432
+ try {
433
+ fileContents.set(file, await fs.readFile(file, 'utf8'));
434
+ } catch { /* skip unreadable */ }
435
+ }
436
+
437
+ const requiredResults = [];
438
+ const optInResults = [];
439
+
440
+ for (const migration of MIGRATIONS) {
441
+ let matchCount = 0;
442
+ const matchedFiles = new Set();
443
+
444
+ for (const [file, content] of fileContents) {
445
+ if (!migration.fileExtensions.some(ext => file.endsWith(ext))) continue;
446
+ for (const pattern of migration.detectPatterns) {
447
+ // Reset lastIndex for global patterns
448
+ const re = new RegExp(pattern.source, pattern.flags.replace('g', ''));
449
+ if (re.test(content)) {
450
+ matchedFiles.add(path.relative(cwd, file));
451
+ // Count all matches
452
+ const globalRe = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
453
+ const matches = content.match(globalRe);
454
+ if (matches) matchCount += matches.length;
455
+ }
456
+ }
457
+ }
458
+
459
+ const result = {
460
+ migration,
461
+ matchCount,
462
+ fileCount: matchedFiles.size,
463
+ files: [...matchedFiles].slice(0, 5), // Show up to 5 sample files
464
+ totalFiles: matchedFiles.size,
465
+ };
466
+
467
+ if (migration.category === 'required') {
468
+ requiredResults.push(result);
469
+ } else {
470
+ optInResults.push(result);
471
+ }
472
+ }
473
+
474
+ // Print required migrations
475
+ console.log(' REQUIRED MIGRATIONS (breaking changes)\n');
476
+ printHealthResults(requiredResults);
477
+
478
+ // Print opt-in migrations
479
+ console.log('\n OPT-IN MIGRATIONS (best practices)\n');
480
+ printHealthResults(optInResults);
481
+
482
+ // Summary
483
+ const requiredPending = requiredResults.filter(r => r.matchCount > 0);
484
+ const optInPending = optInResults.filter(r => r.matchCount > 0);
485
+
486
+ console.log(`\n${'━'.repeat(70)}`);
487
+ console.log(' SUMMARY');
488
+ console.log(`${'━'.repeat(70)}\n`);
489
+
490
+ if (requiredPending.length === 0) {
491
+ console.log(' ✓ All required migrations are complete!\n');
492
+ } else {
493
+ console.log(` ✗ ${requiredPending.length} required migration(s) still pending:\n`);
494
+ for (const r of requiredPending) {
495
+ console.log(` - ${r.migration.name} (${r.matchCount} matches in ${r.fileCount} files)`);
496
+ }
497
+ console.log();
498
+ }
499
+
500
+ if (optInPending.length > 0) {
501
+ console.log(` ○ ${optInPending.length} opt-in migration(s) available:\n`);
502
+ for (const r of optInPending) {
503
+ console.log(` - ${r.migration.name} (${r.matchCount} matches in ${r.fileCount} files)`);
504
+ }
505
+ console.log();
506
+ }
507
+ }
508
+
509
+ function printHealthResults (results) {
510
+ for (const r of results) {
511
+ const status = r.matchCount === 0 ? '✓' : '✗';
512
+ const statusLabel = r.matchCount === 0 ? 'DONE' : 'PENDING';
513
+ const color = r.matchCount === 0 ? '\x1b[32m' : '\x1b[33m';
514
+ const reset = '\x1b[0m';
515
+
516
+ console.log(` ${color}${status} [${statusLabel}]${reset} ${r.migration.name} (${r.migration.id})`);
517
+ console.log(` ${r.migration.description}`);
518
+
519
+ if (r.matchCount > 0) {
520
+ console.log(` → ${r.matchCount} pattern match(es) in ${r.fileCount} file(s)`);
521
+ if (r.files.length > 0) {
522
+ for (const f of r.files) {
523
+ console.log(` ${f}`);
524
+ }
525
+ if (r.totalFiles > 5) {
526
+ console.log(` ... and ${r.totalFiles - 5} more`);
527
+ }
528
+ }
529
+ }
530
+ console.log();
531
+ }
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Interactive selection
536
+ // ---------------------------------------------------------------------------
537
+
538
+ function createReadlineInterface () {
539
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
540
+ }
541
+
542
+ async function prompt (question) {
543
+ const rl = createReadlineInterface();
544
+ return new Promise(resolve => {
545
+ rl.question(question, answer => {
546
+ rl.close();
547
+ resolve(answer.trim().toLowerCase());
548
+ });
549
+ });
550
+ }
551
+
552
+ async function selectMigrations (migrations) {
553
+ console.log('\nAvailable migrations:\n');
554
+
555
+ const required = migrations.filter(m => m.category === 'required');
556
+ const optIn = migrations.filter(m => m.category === 'opt-in');
557
+
558
+ if (required.length > 0) {
559
+ console.log(' REQUIRED (breaking changes):');
560
+ required.forEach((m, i) => {
561
+ console.log(` [${i + 1}] ${m.name} (${m.id})`);
562
+ console.log(` ${m.description}`);
563
+ });
564
+ }
565
+
566
+ if (optIn.length > 0) {
567
+ console.log('\n OPT-IN (best practices):');
568
+ optIn.forEach((m, i) => {
569
+ console.log(` [${required.length + i + 1}] ${m.name} (${m.id})`);
570
+ console.log(` ${m.description}`);
571
+ });
572
+ }
573
+
574
+ const all = [...required, ...optIn];
575
+ console.log(`\n [a] All required migrations`);
576
+ console.log(` [q] Quit\n`);
577
+
578
+ const answer = await prompt('Select migrations (comma-separated numbers, "a" for all required, or "q" to quit): ');
579
+
580
+ if (answer === 'q') return [];
581
+ if (answer === 'a') return required;
582
+
583
+ const indices = answer.split(',').map(s => parseInt(s.trim(), 10) - 1);
584
+ const selected = indices
585
+ .filter(i => i >= 0 && i < all.length)
586
+ .map(i => all[i]);
587
+
588
+ if (selected.length === 0) {
589
+ console.log('No valid selections. Exiting.');
590
+ return [];
591
+ }
592
+
593
+ return selected;
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Migration runner
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * Run a config-based migration using only Node builtins.
602
+ * Reads the config's expressions and applies them to matching files
603
+ * without importing the migration-helper (which requires chalk/globby/inquirer).
604
+ */
605
+ async function runConfigMigration (migration, opts) {
606
+ const configPath = path.resolve(
607
+ __dirname, '..', 'dialtone_migration_helper', 'configs', `${migration.configName}.mjs`,
608
+ );
609
+
610
+ const { default: configData } = await import(configPath);
611
+
612
+ // Derive file extensions from the config's glob patterns
613
+ const extPattern = /\{([^}]+)\}/;
614
+ const extensions = [];
615
+ for (const p of (configData.patterns || [])) {
616
+ const m = p.match(extPattern);
617
+ if (m) {
618
+ for (const ext of m[1].split(',')) {
619
+ const e = '.' + ext.trim();
620
+ if (!extensions.includes(e)) extensions.push(e);
621
+ }
622
+ }
623
+ }
624
+ // Fallback to migration registry extensions if config patterns don't specify
625
+ if (extensions.length === 0) {
626
+ extensions.push(...migration.fileExtensions);
627
+ }
628
+
629
+ console.log(` Configuration: ${migration.configName}`);
630
+ console.log(` ${configData.description.split('\n')[0]}\n`);
631
+
632
+ // Find files using the master script's own walker
633
+ const allFiles = await findFiles(opts.cwd, extensions);
634
+
635
+ // Read and filter files that have matches
636
+ const matched = [];
637
+ for (const file of allFiles) {
638
+ let data;
639
+ try {
640
+ data = await fs.readFile(file, 'utf8');
641
+ } catch { continue; }
642
+ // Skip likely minified files
643
+ if ((data.match(/[\n\r]/g) || []).length <= 3) continue;
644
+ let matchCount = 0;
645
+ for (const expr of configData.expressions) {
646
+ const testRe = new RegExp(expr.from.source, expr.from.flags.replace('g', ''));
647
+ if (testRe.test(data)) matchCount++;
648
+ }
649
+ if (matchCount > 0) {
650
+ matched.push({ file, data, matches: 0 });
651
+ }
652
+ }
653
+
654
+ if (matched.length === 0) {
655
+ console.log(' No matches found. Skipping.\n');
656
+ return { skipped: true };
657
+ }
658
+
659
+ console.log(` ${matched.length} file(s) queued for modification.`);
660
+
661
+ if (opts.dryRun) {
662
+ console.log(' --dry-run: No files were modified.\n');
663
+ for (const f of matched.slice(0, 10)) {
664
+ console.log(` ${path.relative(opts.cwd, f.file)}`);
665
+ }
666
+ if (matched.length > 10) console.log(` ... and ${matched.length - 10} more`);
667
+ return { dryRun: true, fileCount: matched.length };
668
+ }
669
+
670
+ if (!opts.autoYes) {
671
+ const answer = await prompt(` Apply changes to ${matched.length} file(s)? (y/N) `);
672
+ if (answer !== 'y' && answer !== 'yes') {
673
+ console.log(' Skipped.\n');
674
+ return { skipped: true };
675
+ }
676
+ }
677
+
678
+ // Apply expressions and write files.
679
+ // Loop until convergence: some config regexes only match one token per
680
+ // property declaration per pass (e.g. border-width with two var(--dt-size-*)).
681
+ for (const entry of matched) {
682
+ let changed = true;
683
+ while (changed) {
684
+ changed = false;
685
+ for (const expr of configData.expressions) {
686
+ const before = entry.data;
687
+ // String.prototype.replace handles both string ($1 backrefs) and
688
+ // function replacers natively — no callback wrapper needed for
689
+ // strings. The previous match.replace() approach broke lookaheads
690
+ // because the matched substring doesn't include lookahead chars.
691
+ entry.data = entry.data.replace(expr.from, expr.to);
692
+ if (entry.data !== before) {
693
+ entry.matches++;
694
+ changed = true;
695
+ }
696
+ }
697
+ }
698
+ if (entry.matches > 0) {
699
+ await fs.writeFile(entry.file, entry.data, 'utf8');
700
+ const shortname = path.relative(opts.cwd, entry.file);
701
+ console.log(` >> ${shortname}, ${entry.matches} changes`);
702
+ }
703
+ }
704
+
705
+ console.log();
706
+ return { applied: true, fileCount: matched.length };
707
+ }
708
+
709
+ /**
710
+ * Run a standalone migration script as a child process.
711
+ */
712
+ async function runStandaloneMigration (migration, opts) {
713
+ const scriptPath = path.resolve(__dirname, '..', migration.scriptDir, 'index.mjs');
714
+
715
+ const args = ['--cwd', opts.cwd];
716
+ if (opts.dryRun) args.push('--dry-run');
717
+ if (opts.autoYes) args.push('--yes');
718
+
719
+ return new Promise((resolve, reject) => {
720
+ const child = spawn(process.execPath, [scriptPath, ...args], {
721
+ stdio: 'inherit',
722
+ cwd: process.cwd(),
723
+ });
724
+
725
+ child.on('close', code => {
726
+ // Exit code 0 means success (or nothing to do)
727
+ resolve({ exitCode: code });
728
+ });
729
+
730
+ child.on('error', reject);
731
+ });
732
+ }
733
+
734
+ async function runMigration (migration, opts) {
735
+ if (migration.type === 'config') {
736
+ return runConfigMigration(migration, opts);
737
+ }
738
+
739
+ if (migration.type === 'standalone') {
740
+ return runStandaloneMigration(migration, opts);
741
+ }
742
+
743
+ console.log(` ⚠ Manual migration required. See migration guide for details.\n`);
744
+ return { manual: true };
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Main
749
+ // ---------------------------------------------------------------------------
750
+
751
+ async function main () {
752
+ const opts = parseArgs(process.argv.slice(2));
753
+
754
+ if (opts.help) {
755
+ printHelp();
756
+ process.exit(0);
757
+ }
758
+
759
+ console.log(`\n${'━'.repeat(70)}`);
760
+ console.log(' DIALTONE MIGRATION TOOL');
761
+ console.log(`${'━'.repeat(70)}`);
762
+
763
+ // Health check mode
764
+ if (opts.healthCheck) {
765
+ await healthCheck(opts.cwd);
766
+ process.exit(0);
767
+ }
768
+
769
+ // Determine which migrations to run
770
+ let selected;
771
+
772
+ if (opts.only) {
773
+ selected = opts.only.map(id => {
774
+ const m = MIGRATIONS.find(m => m.id === id);
775
+ if (!m) {
776
+ console.error(`\n Unknown migration ID: "${id}"`);
777
+ console.error(` Available IDs: ${MIGRATIONS.map(m => m.id).join(', ')}\n`);
778
+ process.exit(1);
779
+ }
780
+ return m;
781
+ });
782
+ } else if (opts.all) {
783
+ selected = MIGRATIONS.filter(m => m.category === 'required');
784
+ } else {
785
+ selected = await selectMigrations(MIGRATIONS);
786
+ }
787
+
788
+ if (selected.length === 0) {
789
+ console.log('\n No migrations selected. Exiting.\n');
790
+ process.exit(0);
791
+ }
792
+
793
+ // Confirmation
794
+ console.log(`\n Target directory: ${opts.cwd}`);
795
+ console.log(` Migrations to run (${selected.length}):\n`);
796
+ for (const m of selected) {
797
+ const tag = m.category === 'required' ? '[REQUIRED]' : '[OPT-IN]';
798
+ console.log(` ${tag} ${m.name}`);
799
+ }
800
+
801
+ if (opts.dryRun) {
802
+ console.log('\n Mode: DRY RUN (no files will be modified)\n');
803
+ }
804
+
805
+ if (!opts.autoYes && !opts.dryRun) {
806
+ console.log(`\n ⚠ Please ensure you are running this in a repository where changes`);
807
+ console.log(` can be rolled back. Modifications will occur to files.\n`);
808
+
809
+ const answer = await prompt(' Proceed? (y/N) ');
810
+ if (answer !== 'y' && answer !== 'yes') {
811
+ console.log(' Cancelled.\n');
812
+ process.exit(0);
813
+ }
814
+ }
815
+
816
+ // Run migrations in order
817
+ const results = [];
818
+
819
+ for (let i = 0; i < selected.length; i++) {
820
+ const migration = selected[i];
821
+ console.log(`\n${'─'.repeat(70)}`);
822
+ console.log(` [${i + 1}/${selected.length}] ${migration.name} (${migration.id})`);
823
+ console.log(`${'─'.repeat(70)}\n`);
824
+
825
+ try {
826
+ const result = await runMigration(migration, opts);
827
+ results.push({ migration, ...result, success: true });
828
+ } catch (err) {
829
+ console.error(`\n ✗ Error running ${migration.name}: ${err.message}\n`);
830
+ results.push({ migration, success: false, error: err.message });
831
+
832
+ const answer = await prompt(' Continue with remaining migrations? (y/N) ');
833
+ if (answer !== 'y' && answer !== 'yes') break;
834
+ }
835
+ }
836
+
837
+ // Final summary
838
+ console.log(`\n${'━'.repeat(70)}`);
839
+ console.log(' MIGRATION SUMMARY');
840
+ console.log(`${'━'.repeat(70)}\n`);
841
+
842
+ for (const r of results) {
843
+ const status = r.success
844
+ ? (r.skipped ? '○ SKIPPED' : r.dryRun ? '◐ DRY RUN' : r.manual ? '⚠ MANUAL' : '✓ DONE')
845
+ : '✗ FAILED';
846
+ console.log(` ${status} ${r.migration.name}`);
847
+ if (!r.success && r.error) console.log(` ${r.error}`);
848
+ }
849
+
850
+ console.log(`\n Tip: Run with --health-check to verify remaining migration work.\n`);
851
+ }
852
+
853
+ main().catch(err => {
854
+ console.error(err);
855
+ process.exit(1);
856
+ });