orbiq-neural-ui-kit 0.0.1

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,938 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Espresso v2 token migration codemod.
4
+ *
5
+ * Renames old semantic token names (frappe-ui v0.1.278 era) to their espresso
6
+ * v2 equivalents, per the "Frappe tokens v2 migration" mapping from design.
7
+ * Works on any text occurrence of a token name — tailwind utilities
8
+ * (`bg-surface-white`, `text-ink-red-2`, `border-outline-gray-modals`) and CSS
9
+ * variables (`var(--surface-white)`) alike — so it can be pointed at app
10
+ * codebases too.
11
+ *
12
+ * It also merges weight classes: a `text-<size>` + `font-<weight>` in the same
13
+ * static class list collapses to the combined `text-<size>-<weight>` style
14
+ * class, which carries the correct per-weight letter-spacing (see
15
+ * `mergeWeightClasses`).
16
+ *
17
+ * Usage: tokens-v2 [--dry-run] [--force] <dir-or-file...>
18
+ *
19
+ * IMPORTANT: the token replacement is single-pass/simultaneous. Several renames
20
+ * chain (outline red-2→3, red-3→4, red-4→5); applying them sequentially would
21
+ * cascade (red-2 ending up as red-5). Color renames must run exactly once per
22
+ * codebase — the v2 scheme reuses names (e.g. surface-gray-5 exists in both
23
+ * scales with different values), so a second full run would double-shift tokens.
24
+ * As a guard, the script detects already-migrated codebases and runs only the
25
+ * typography correction (`text-lg` → `text-md`, `text-xl` → `text-lg`, ...).
26
+ * Pass --force to run the full migration anyway.
27
+ *
28
+ * `tiny` and `13xl`-`16xl` were dropped as unused vocabulary in a pre-1.0
29
+ * audit (#940) — no utility ships for them anymore. A class that would have
30
+ * shifted onto one of them is left untouched and reported under "needs
31
+ * manual attention" instead of being rewritten to a dead class.
32
+ *
33
+ * Radius aliases (`rounded`, `rounded-sm/md/lg/xl/2xl`, and their directional
34
+ * forms) were removed in 1.0.0 per ADR-0006. The codemod renames them to the
35
+ * numbered tokens (`rounded-4`, `rounded-1/5/6/7/8`). These renames are
36
+ * idempotent and run in every mode. `rounded-none` and `rounded-full` are
37
+ * kept tokens and stay untouched. The bare word `rounded` is also plain
38
+ * English, so it is only rewritten in class-like contexts (inside a quoted
39
+ * string or an `@apply` rule) — prose and comments keep their words.
40
+ *
41
+ * A codebase that already ran BOTH the color migration and the typography
42
+ * correction must not run either again (both shift names). Pass
43
+ * --radius-only there: it runs only the (idempotent) radius renames and the
44
+ * removed-token report.
45
+ *
46
+ * The `text-<size>-black` style classes were removed in 1.0.0 (#998, zero
47
+ * usage; the Figma weights behind them were corrupt export data). Existing
48
+ * `text-*-black` usage is flagged, never renamed, and a `font-extrabold` /
49
+ * `font-black` next to a text size is flagged instead of merged.
50
+ *
51
+ * --ink-shift (#1016): the updated espresso v2 Figma export shifts every
52
+ * chromatic ink scale down one level (new `ink-red-1` = old `ink-red-2`;
53
+ * `ink-gray` is its own 9-step scale and does NOT shift). This mode renames
54
+ * `ink-<family>-N` → `ink-<family>-(N-1)` for N in 2..10, chromatic families
55
+ * only, and runs NOTHING else — no color renames, no typography, no radius,
56
+ * no weight merges. Like the color migration, there is no sentinel that can
57
+ * tell whether the shift already ran (`ink-red-5` is a valid name on both
58
+ * sides), so --ink-shift MUST run exactly once per codebase — a second run
59
+ * would double-shift. --ink-shift takes directory targets only; a real run
60
+ * writes a `.tokens-v2-ink-shift` marker file in each target directory and
61
+ * refuses to run again while one exists there, in any ancestor directory,
62
+ * or anywhere in the target subtree. Symlinks that leave the target subtree
63
+ * are skipped and reported, never rewritten — an external package must be
64
+ * migrated directly so it gets its own marker.
65
+ * Old `ink-<family>-1` (the neutral-white step) has no
66
+ * automatic destination (the new `-1` is a light tint, not white); it is
67
+ * flagged for manual attention, never rewritten.
68
+ */
69
+
70
+ import fs from 'fs'
71
+ import path from 'path'
72
+ import { fileURLToPath } from 'url'
73
+
74
+ const USAGE = 'Usage: tokens-v2 [--dry-run] [--force] [--radius-only | --ink-shift] <dir-or-file...>'
75
+
76
+ // ---------- MAPPING ----------
77
+
78
+ // Per-category renames: old suffix → new suffix.
79
+ // `cards` / `gray-modals` are code-side legacy aliases of Figma's
80
+ // `card` / `gray-modal` — both spellings map to the same v2 token.
81
+ const shift = (families, pairs) =>
82
+ Object.fromEntries(
83
+ families.flatMap((f) => pairs.map(([from, to]) => [`${f}-${from}`, `${f}-${to}`])),
84
+ )
85
+
86
+ const ACCENTS = ['red', 'blue', 'green', 'amber', 'violet']
87
+
88
+ const SURFACE_RENAMES = {
89
+ white: 'base',
90
+ 'menu-bar': 'sidebar',
91
+ card: 'elevation-1',
92
+ cards: 'elevation-1',
93
+ modal: 'elevation-2',
94
+ selected: 'elevation-3',
95
+ // legacy code-side token; resolved to the same values as elevation-3 in both modes
96
+ 'gray-2-contrast': 'elevation-3',
97
+ ...shift(['gray'], [[5, 8], [6, 9], [7, 10]]),
98
+ ...shift(ACCENTS, [[5, 7], [6, 8], [7, 9]]),
99
+ }
100
+
101
+ const INK_RENAMES = {
102
+ white: 'base',
103
+ ...shift(ACCENTS, [[2, 5], [3, 6], [4, 8]]),
104
+ }
105
+
106
+ const OUTLINE_RENAMES = {
107
+ white: 'base',
108
+ 'gray-modal': 'elevation-2',
109
+ 'gray-modals': 'elevation-2',
110
+ ...shift(['gray'], [[5, 7]]),
111
+ ...shift(ACCENTS, [[2, 3], [3, 4], [4, 5]]),
112
+ }
113
+
114
+ // The alpha categories only kept their neutral ramps in v2.
115
+ const SURFACE_ALPHA_RENAMES = {
116
+ white: 'base',
117
+ 'menu-bar': 'sidebar',
118
+ card: 'elevation-1',
119
+ cards: 'elevation-1',
120
+ modal: 'elevation-2',
121
+ selected: 'elevation-3',
122
+ ...shift(['gray'], [[5, 8], [6, 9], [7, 10]]),
123
+ }
124
+
125
+ const OUTLINE_ALPHA_RENAMES = {
126
+ white: 'base',
127
+ 'gray-modal': 'elevation-2',
128
+ 'gray-modals': 'elevation-2',
129
+ ...shift(['gray'], [[5, 7]]),
130
+ }
131
+
132
+ // ---------- TYPOGRAPHY SIZE RENAMES ----------
133
+
134
+ // The espresso text scale gained 15px (`md`) and 17px (`xl`) stops. Each
135
+ // physical size keeps its meaning under a new name, so existing utility classes
136
+ // must be renamed to render identically. These chain and so MUST run in a single
137
+ // simultaneous pass — a sequential pass would cascade.
138
+ //
139
+ // Caveat: these names (`text-lg` … `text-9xl`) coincide with stock Tailwind
140
+ // font-size utilities, so only point this at code using the espresso scale, and
141
+ // run exactly once (see header).
142
+
143
+ // `tiny` and `13xl`-`16xl` were removed as unused vocabulary in #940 — no
144
+ // utility ships for them anymore. Must stay in sync with `DROPPED_SIZES` in
145
+ // `figma-tokens-to-theme.js`. Filtered out of the shift tables below so the
146
+ // codemod can never *rename a class onto one of these dead sizes*; see the
147
+ // "no destination" handling further down for reporting pre-existing usage.
148
+ const DEAD_SIZES = ['tiny', '13xl', '14xl', '15xl', '16xl']
149
+
150
+ const UNMIGRATED_TEXT_SIZE_SHIFT_ALL = [
151
+ ['xl', '2xl'],
152
+ ['2xl', '3xl'],
153
+ ['3xl', '4xl'],
154
+ ['4xl', '5xl'],
155
+ ['5xl', '6xl'],
156
+ ['6xl', '7xl'],
157
+ ['7xl', '8xl'],
158
+ ['8xl', '9xl'],
159
+ ['9xl', '10xl'],
160
+ ['10xl', '11xl'],
161
+ ['11xl', '12xl'],
162
+ ['12xl', '13xl'],
163
+ ['13xl', '14xl'],
164
+ ['14xl', '15xl'],
165
+ ['15xl', '16xl'],
166
+ ]
167
+ // Old-scale `12xl`-`15xl` would have shifted onto a size dropped in #940 —
168
+ // capped here so they're left untouched (and flagged, see below) instead of
169
+ // renamed to a class that no longer emits CSS.
170
+ const UNMIGRATED_TEXT_SIZE_SHIFT = UNMIGRATED_TEXT_SIZE_SHIFT_ALL.filter(
171
+ ([, to]) => !DEAD_SIZES.includes(to),
172
+ )
173
+
174
+ // Apps that already ran the original v2 codemod use the temporary typography
175
+ // names where `text-lg` was 15px and `text-xl` was 16px. Shift those one step
176
+ // down into the corrected names without touching color tokens.
177
+ const MIGRATED_TEXT_SIZE_SHIFT_ALL = [
178
+ ['lg', 'md'],
179
+ ['xl', 'lg'],
180
+ ['2xl', 'xl'],
181
+ ['3xl', '2xl'],
182
+ ['4xl', '3xl'],
183
+ ['5xl', '4xl'],
184
+ ['6xl', '5xl'],
185
+ ['7xl', '6xl'],
186
+ ['8xl', '7xl'],
187
+ ['9xl', '8xl'],
188
+ ['10xl', '9xl'],
189
+ ['11xl', '10xl'],
190
+ ['12xl', '11xl'],
191
+ ['13xl', '12xl'],
192
+ ['14xl', '13xl'],
193
+ ['15xl', '14xl'],
194
+ ['16xl', '15xl'],
195
+ ['17xl', '16xl'],
196
+ ]
197
+ // Temp-scale `14xl`-`17xl` would have corrected onto a size dropped in #940 —
198
+ // capped for the same reason as above.
199
+ const MIGRATED_TEXT_SIZE_SHIFT = MIGRATED_TEXT_SIZE_SHIFT_ALL.filter(
200
+ ([, to]) => !DEAD_SIZES.includes(to),
201
+ )
202
+
203
+ // Each size surfaces as a bare size utility, a paragraph variant, and weighted
204
+ // component classes. All forms shift together. `-black` is absent on purpose:
205
+ // the black style classes were removed in #998, so a `text-*-black` is never
206
+ // renamed — it's flagged (see BLACK_STYLE_TOKENS below).
207
+ const textSizeRenames = (shift) => Object.fromEntries(
208
+ shift.flatMap(([from, to]) => [
209
+ [`text-${from}`, `text-${to}`],
210
+ [`text-p-${from}`, `text-p-${to}`],
211
+ [`text-${from}-medium`, `text-${to}-medium`],
212
+ [`text-p-${from}-medium`, `text-p-${to}-medium`],
213
+ [`text-${from}-semibold`, `text-${to}-semibold`],
214
+ [`text-p-${from}-semibold`, `text-p-${to}-semibold`],
215
+ [`text-${from}-bold`, `text-${to}-bold`],
216
+ [`text-p-${from}-bold`, `text-p-${to}-bold`],
217
+ ]),
218
+ )
219
+
220
+ const UNMIGRATED_TEXT_SIZE_RENAMES = textSizeRenames(UNMIGRATED_TEXT_SIZE_SHIFT)
221
+ const MIGRATED_TEXT_SIZE_RENAMES = textSizeRenames(MIGRATED_TEXT_SIZE_SHIFT)
222
+
223
+ // ---------- RADIUS RENAMES ----------
224
+
225
+ // ADR-0006 / #998: the named radius aliases are removed in 1.0.0 in favor of
226
+ // the numbered scale. The map is deterministic (bare `rounded` = 8px =
227
+ // `rounded-4`; sm→1, md→5, lg→6, xl→7, 2xl→8). `rounded-none` and
228
+ // `rounded-full` are kept tokens, never touched. Unlike the color renames,
229
+ // these are idempotent (no numbered token appears on the left), so they run in
230
+ // every mode.
231
+ const RADIUS_STEP_BY_ALIAS = { sm: '1', md: '5', lg: '6', xl: '7', '2xl': '8' }
232
+ const RADIUS_BARE_STEP = '4'
233
+ // Every side prefix Tailwind derives from the borderRadius scale, including
234
+ // the 3.3+ logical sides. Numbered directional utilities (`rounded-t-6`,
235
+ // `rounded-ss-1`, …) exist for all of them.
236
+ const RADIUS_SIDES = ['t', 'r', 'b', 'l', 'tl', 'tr', 'br', 'bl', 's', 'e', 'ss', 'se', 'es', 'ee']
237
+
238
+ export const RADIUS_RENAMES = {}
239
+ for (const side of RADIUS_SIDES.map((s) => `-${s}`)) {
240
+ // Bare directional alias: `rounded-t` = `rounded-t-4`. (The side-less bare
241
+ // `rounded` is also plain English — see BARE_ROUNDED_REGEX below.)
242
+ RADIUS_RENAMES[`rounded${side}`] = `rounded${side}-${RADIUS_BARE_STEP}`
243
+ }
244
+ for (const side of ['', ...RADIUS_SIDES.map((s) => `-${s}`)]) {
245
+ for (const [alias, step] of Object.entries(RADIUS_STEP_BY_ALIAS)) {
246
+ RADIUS_RENAMES[`rounded${side}-${alias}`] = `rounded${side}-${step}`
247
+ }
248
+ }
249
+
250
+ // The bare `rounded` utility is an ordinary English word ("values are rounded
251
+ // to px"), so a global text rename would corrupt prose and comments. It is
252
+ // rewritten only in class-like contexts — see isClassContext().
253
+ const BARE_ROUNDED_REGEX = /(?<![a-zA-Z0-9])rounded(?![a-zA-Z0-9-])/g
254
+
255
+ // A bare `rounded` at `offset` counts as a class when its line puts it inside
256
+ // a quoted string (class attributes, JS class lists, template literals) or in
257
+ // an `@apply` rule. Prose in markdown and comments has neither. Known gap: a
258
+ // class list inside a multi-line template literal has no quote on its own
259
+ // line and is skipped — grep for bare `rounded` after running the codemod.
260
+
261
+ // An apostrophe inside a word (`row's`, `it's`) is not a string delimiter —
262
+ // without this, `// the row's corners are rounded when it's hovered` would
263
+ // count as "inside quotes" and get rewritten.
264
+ const stripContractions = (s) => s.replace(/(?<=\w)'(?=\w)/g, '')
265
+
266
+ function isClassContext(content, offset) {
267
+ const lineStart = content.lastIndexOf('\n', offset - 1) + 1
268
+ const lineEndIdx = content.indexOf('\n', offset)
269
+ const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx
270
+ const before = content.slice(lineStart, offset)
271
+ const after = content.slice(offset, lineEnd)
272
+ if (/@apply[^;]*$/.test(before)) return true
273
+ for (const quote of ['"', "'", '`']) {
274
+ const b = quote === "'" ? stripContractions(before) : before
275
+ const a = quote === "'" ? stripContractions(after) : after
276
+ const opensBefore = (b.split(quote).length - 1) % 2 === 1
277
+ if (opensBefore && a.includes(quote)) return true
278
+ }
279
+ return false
280
+ }
281
+
282
+ // Full old token name → full new token name. NOTE: alpha categories must come
283
+ // before their base category when building the alternation so that e.g.
284
+ // `surface-alpha-gray-5` is never half-matched by a `surface-…` rule (the
285
+ // longest-first sort below also guarantees this).
286
+ export const COLOR_TOKEN_RENAMES = {
287
+ ...prefix('surface-alpha', SURFACE_ALPHA_RENAMES),
288
+ ...prefix('outline-alpha', OUTLINE_ALPHA_RENAMES),
289
+ ...prefix('surface', SURFACE_RENAMES),
290
+ ...prefix('ink', INK_RENAMES),
291
+ ...prefix('outline', OUTLINE_RENAMES),
292
+ }
293
+
294
+ export const TOKEN_RENAMES = {
295
+ ...COLOR_TOKEN_RENAMES,
296
+ ...UNMIGRATED_TEXT_SIZE_RENAMES,
297
+ ...RADIUS_RENAMES,
298
+ }
299
+
300
+ // Radius renames are idempotent, so they also run when the color/typography
301
+ // migration is already done.
302
+ const MIGRATED_MODE_RENAMES = {
303
+ ...MIGRATED_TEXT_SIZE_RENAMES,
304
+ ...RADIUS_RENAMES,
305
+ }
306
+
307
+ // The full text-size vocabulary ever seen by the codemod (live, dead, and
308
+ // temp-scale names). Used for weight merging and the black-style flag list.
309
+ const TEXT_SIZES = [
310
+ 'tiny', '2xs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl', '4xl',
311
+ '5xl', '6xl', '7xl', '8xl', '9xl', '10xl', '11xl', '12xl', '13xl', '14xl',
312
+ '15xl', '16xl', '17xl',
313
+ ]
314
+
315
+ // A dead size surfaces as a bare size utility and weighted component classes
316
+ // (no `text-p-*` form — paragraph styles only ever went up to `4xl` and never
317
+ // had a `tiny`, so those combinations never existed).
318
+ const deadSizeTokens = (sizes) => sizes.flatMap((s) => [
319
+ `text-${s}`, `text-${s}-medium`, `text-${s}-semibold`, `text-${s}-bold`, `text-${s}-black`,
320
+ ])
321
+
322
+ // #998: the black weight styles were removed with zero usage. Any literal
323
+ // `text-*-black` is dead in every mode — flagged, never renamed.
324
+ const BLACK_STYLE_TOKENS = TEXT_SIZES.flatMap((s) => [
325
+ `text-${s}-black`,
326
+ `text-p-${s}-black`,
327
+ ])
328
+
329
+ // Tokens dropped in v2 with no replacement — usage is reported, never rewritten.
330
+ export const REMOVED_TOKENS = [
331
+ ...[1, 2, 3, 4, 5, 6, 7].map((n) => `surface-alpha-red-${n}`),
332
+ ...[2, 3, 4].map((n) => `outline-alpha-red-${n}`),
333
+ // #940: dead in every mode — either the v2 name directly, or (for `tiny`,
334
+ // which never shifted) the only name it ever had.
335
+ ...deadSizeTokens(DEAD_SIZES),
336
+ ...BLACK_STYLE_TOKENS,
337
+ ]
338
+
339
+ // Old-scale `12xl` is only dead in *unmigrated* content: there it's the size
340
+ // that used to shift onto the now-dead v2 `13xl` (see the capped shift table
341
+ // above). In already-migrated content, literal `text-12xl` is the healthy,
342
+ // current v2 name and must not be flagged.
343
+ const UNMIGRATED_REMOVED_TOKENS = [...REMOVED_TOKENS, ...deadSizeTokens(['12xl'])]
344
+ // The migrated-temp-scale `17xl` never shipped as a real token in any mode —
345
+ // it only ever existed as the pre-correction name that used to correct onto
346
+ // the now-dead v2 `16xl`.
347
+ const MIGRATED_REMOVED_TOKENS = [...REMOVED_TOKENS, ...deadSizeTokens(['17xl'])]
348
+
349
+ // Legacy names with no v2 mapping decided yet — reported for manual review.
350
+ export const WATCH_TOKENS = []
351
+
352
+ // ---------- INK SCALE SHIFT (#1016) ----------
353
+
354
+ // The updated espresso v2 export drops the neutral-white `-1` step from every
355
+ // chromatic ink scale, so each level moves down one (new `-1` = old `-2`, …,
356
+ // new `-9` = old `-10`; the scales now end at `-9`). `ink-gray` keeps its own
357
+ // 9-step scale and does not shift. The shift is bounded to N=2..10 with
358
+ // whole-token maps on purpose: an unbounded numeric rewrite would corrupt a
359
+ // stale non-v2 token like `ink-blue-600` into `ink-blue-599`.
360
+ const CHROMATIC_INK_FAMILIES = [
361
+ 'red', 'green', 'blue', 'amber', 'violet', 'yellow', 'orange', 'teal',
362
+ 'cyan', 'purple', 'pink',
363
+ ]
364
+
365
+ export const INK_SHIFT_RENAMES = prefix(
366
+ 'ink',
367
+ shift(CHROMATIC_INK_FAMILIES, [
368
+ [2, 1], [3, 2], [4, 3], [5, 4], [6, 5], [7, 6], [8, 7], [9, 8], [10, 9],
369
+ ]),
370
+ )
371
+
372
+ // Old `ink-<family>-1` was neutral/white in both themes. The new `-1` is a
373
+ // light tint (100), and rewriting the token text to `white` would corrupt
374
+ // `var(--ink-red-1)` into `var(--white)` (which doesn't exist) — so these are
375
+ // flagged for manual attention, never rewritten.
376
+ export const INK_SHIFT_FLAGGED_TOKENS = CHROMATIC_INK_FAMILIES.map(
377
+ (f) => `ink-${f}-1`,
378
+ )
379
+
380
+ // `ink-red-5` is a valid name before and after the shift, so file content
381
+ // cannot reveal a prior run. A marker file anchored to the migrated target
382
+ // (not the caller's working directory — the codemod is often run from
383
+ // elsewhere with an explicit path) is the only guard against a silent
384
+ // double-shift.
385
+ export const INK_SHIFT_MARKER = '.tokens-v2-ink-shift'
386
+
387
+ // Search the directory and every ancestor: a run on a repo root must also
388
+ // block a later run on one of its subdirectories.
389
+ // `ignore` holds the markers this run wrote itself. The claim-then-verify
390
+ // pass searches again after writing, and must not stop on its own marker.
391
+ export function findInkShiftMarker(dir, { ignore = new Set() } = {}) {
392
+ // realpath, not resolve: a symlink alias must share the identity of its
393
+ // target, or it bypasses the guard.
394
+ let current = fs.realpathSync(dir)
395
+ for (;;) {
396
+ const file = path.join(current, INK_SHIFT_MARKER)
397
+ if (fs.existsSync(file) && !ignore.has(file)) return file
398
+ const parent = path.dirname(current)
399
+ if (parent === current) return null
400
+ current = parent
401
+ }
402
+ }
403
+
404
+ // True when a real path is one of the roots or sits inside one.
405
+ function isInsideRoots(real, roots) {
406
+ return roots.some((r) => real === r || real.startsWith(r + path.sep))
407
+ }
408
+
409
+ // Search the subtree: a run on a subdirectory must also block a later run on
410
+ // its parent, or the already-shifted subtree double-shifts. Mirrors walk()'s
411
+ // directory skip list and its symlink rule — the search must cover exactly
412
+ // what the run would rewrite. A link out of the target subtrees is skipped:
413
+ // walk() does not rewrite it, so a marker there belongs to another codebase
414
+ // and must not block this run.
415
+ export function findInkShiftMarkerBelow(
416
+ dir,
417
+ { roots = null, seenDirs = new Set(), ignore = new Set() } = {},
418
+ ) {
419
+ const resolved = fs.realpathSync(dir)
420
+ const bounds = roots ?? [resolved]
421
+ if (seenDirs.has(resolved)) return null
422
+ seenDirs.add(resolved)
423
+ const file = path.join(resolved, INK_SHIFT_MARKER)
424
+ if (fs.existsSync(file) && !ignore.has(file)) return file
425
+ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
426
+ if (SKIP_DIRS.has(entry.name)) continue
427
+ const full = path.join(resolved, entry.name)
428
+ let isDirectory = entry.isDirectory()
429
+ if (!isDirectory && entry.isSymbolicLink()) {
430
+ try {
431
+ isDirectory = fs.statSync(full).isDirectory()
432
+ } catch {
433
+ continue // broken symlink
434
+ }
435
+ if (isDirectory && !isInsideRoots(fs.realpathSync(full), bounds)) continue
436
+ }
437
+ if (!isDirectory) continue
438
+ const found = findInkShiftMarkerBelow(full, { roots: bounds, seenDirs, ignore })
439
+ if (found) return found
440
+ }
441
+ return null
442
+ }
443
+
444
+ // `wx` fails when the file exists, so the create is the claim. Reading the
445
+ // guard and then writing leaves a window in which two concurrent runs both
446
+ // pass and both shift the same files; an exclusive create closes it for the
447
+ // target directories. Nested targets (a root run against a subdirectory run)
448
+ // still race — the ancestor and subtree searches cannot be atomic — which is
449
+ // why the guide says to run the shift once, per package root.
450
+ export function writeInkShiftMarker(dir) {
451
+ fs.writeFileSync(
452
+ path.join(dir, INK_SHIFT_MARKER),
453
+ `The ink scale shift (tokens-v2 --ink-shift, #1016) ran here on ${new Date().toISOString()}.\n` +
454
+ 'A second run would double-shift every chromatic ink token.\n' +
455
+ 'Delete this file only to re-run the shift on purpose.\n',
456
+ { flag: 'wx' },
457
+ )
458
+ }
459
+
460
+ function prefix(category, renames) {
461
+ return Object.fromEntries(
462
+ Object.entries(renames).map(([from, to]) => [
463
+ `${category}-${from}`,
464
+ `${category}-${to}`,
465
+ ]),
466
+ )
467
+ }
468
+
469
+ // ---------- REPLACEMENT ----------
470
+
471
+ // Token names are matched whole: not preceded by a letter/digit (a leading `-`
472
+ // is expected — `bg-surface-white`, `var(--surface-white)`) and not followed by
473
+ // anything that could extend the name (`gray-1` must not match in `gray-10`,
474
+ // `card` not in `cards`, `gray-modal` not in `gray-modals`,
475
+ // `surface-gray-2` not in `surface-gray-2-contrast`).
476
+ const byLengthDesc = (a, b) => b.length - a.length
477
+
478
+ const renameRegexFor = (renames) => new RegExp(
479
+ `(?<![a-zA-Z0-9])(${Object.keys(renames).sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`,
480
+ 'g',
481
+ )
482
+ const buildFlagRegex = (tokens) => new RegExp(
483
+ `(?<![a-zA-Z0-9])(${[...tokens, ...WATCH_TOKENS].sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`,
484
+ 'g',
485
+ )
486
+ // Which "no replacement" tokens to flag depends on mode — see the `12xl` /
487
+ // `17xl` note above. Radius-only content is presumed migrated (a literal
488
+ // `text-12xl` there is the healthy v2 name), so it shares the migrated list.
489
+ const FULL_FLAG_REGEX = buildFlagRegex(UNMIGRATED_REMOVED_TOKENS)
490
+ const MIGRATED_FLAG_REGEX = buildFlagRegex(MIGRATED_REMOVED_TOKENS)
491
+ // Ink-shift mode flags only the destination-less `ink-<family>-1` tokens —
492
+ // the v1→v2 removed-token report belongs to the other modes.
493
+ const INK_SHIFT_FLAG_REGEX = buildFlagRegex(INK_SHIFT_FLAGGED_TOKENS)
494
+ const flagRegexFor = (mode) => {
495
+ if (mode === 'ink-shift') return INK_SHIFT_FLAG_REGEX
496
+ return mode === 'full' ? FULL_FLAG_REGEX : MIGRATED_FLAG_REGEX
497
+ }
498
+
499
+ // ---------- WEIGHT-CLASS MERGE ----------
500
+
501
+ // Collapse a `text-<size>` and a `font-<weight>` that co-occur in the same
502
+ // static class list into the combined `text-<size>-<weight>` style class — the
503
+ // canonical way to express a weighted text style now that letter-spacing is
504
+ // tracked per weight (so `text-base font-medium` is NOT equivalent to
505
+ // `text-base-medium`). The two need not be adjacent; classes in between
506
+ // (`px-2 text-sm font-medium text-ink-gray-7`) are preserved, and color
507
+ // utilities like `text-ink-gray-7` are never mistaken for a size.
508
+ //
509
+ // Only *static* `class="…"` / `className="…"` attributes are touched. Dynamic
510
+ // `:class` / `v-bind:class` and conditional weights are skipped on purpose:
511
+ // merging a conditionally-applied weight into an unconditional size would be
512
+ // wrong. A class list with more than one size or more than one weight is
513
+ // ambiguous and left untouched.
514
+ const WEIGHT_SUFFIX = {
515
+ 'font-medium': 'medium',
516
+ 'font-semibold': 'semibold',
517
+ 'font-bold': 'bold',
518
+ 'font-normal': '', // regular — drop the weight; the bare `text-<size>` is regular
519
+ }
520
+
521
+ // #998 removed the `text-*-black` styles, so the black-ish weight utilities
522
+ // have no style class to merge onto. A size + one of these is flagged for
523
+ // manual attention instead of merged (`font-extrabold` was the old merge
524
+ // source for `-black`; `font-black` is Tailwind's 900, which never had an
525
+ // espresso style).
526
+ const BLACK_WEIGHT_CLASSES = ['font-extrabold', 'font-black']
527
+ const SIZE_CLASS = new RegExp(`^text-(?:p-)?(?:${TEXT_SIZES.join('|')})$`)
528
+
529
+ // Static class/className attribute value — not `:class` / `v-bind:class` (the
530
+ // negative lookbehind rejects a preceding `:` or `-`).
531
+ const CLASS_ATTR = /(?<![:\w-])(class(?:Name)?)(\s*=\s*)(["'])([^"']*)\3/g
532
+
533
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
534
+ // Whole-token matcher within a space-separated class list (consumes one leading
535
+ // space so removing a mid-list token doesn't leave a double space).
536
+ const tokenRe = (tok) => new RegExp(`(?:^|\\s)${escapeRe(tok)}(?=\\s|$)`)
537
+
538
+ export function mergeWeightClasses(content) {
539
+ const merges = []
540
+ const flagged = []
541
+ const migrated = content.replace(CLASS_ATTR, (full, name, eq, quote, value, offset) => {
542
+ const words = value.split(/\s+/).filter(Boolean)
543
+ const sizes = words.filter((w) => SIZE_CLASS.test(w))
544
+ const weights = words.filter((w) => w in WEIGHT_SUFFIX)
545
+ const blacks = words.filter((w) => BLACK_WEIGHT_CLASSES.includes(w))
546
+ if (sizes.length === 1 && blacks.length > 0) {
547
+ // Would have merged onto a removed `text-*-black` class — flag instead.
548
+ flagged.push({
549
+ token: `${sizes[0]} + ${blacks[0]}`,
550
+ line: lineAt(content, offset),
551
+ })
552
+ return full
553
+ }
554
+ if (sizes.length !== 1 || weights.length !== 1) return full
555
+
556
+ const size = sizes[0]
557
+ const weight = weights[0]
558
+ const suffix = WEIGHT_SUFFIX[weight]
559
+ const merged = suffix ? `${size}-${suffix}` : size
560
+
561
+ const newValue = value
562
+ .replace(new RegExp(`(^|\\s)${escapeRe(size)}(?=\\s|$)`), (m, pre) => `${pre}${merged}`)
563
+ .replace(tokenRe(weight), '')
564
+ merges.push({ from: `${size} + ${weight}`, to: merged, line: lineAt(content, offset) })
565
+ return `${name}${eq}${quote}${newValue}${quote}`
566
+ })
567
+ return { migrated, merges, flagged }
568
+ }
569
+
570
+ // ---------- ALREADY-MIGRATED DETECTION ----------
571
+
572
+ // Tokens that exist ONLY pre-migration (renamed away) vs ONLY post-migration. A
573
+ // codebase with post-migration names has almost certainly been migrated already
574
+ // or partially. Re-running color renames is destructive because the color
575
+ // renames reuse names, so default to typography-only when v2 sentinels exist.
576
+ const PRE_MIGRATION_TOKENS = [
577
+ 'surface-white', 'ink-white', 'outline-white', 'surface-menu-bar',
578
+ 'surface-card', 'surface-cards', 'surface-modal', 'surface-selected',
579
+ 'outline-gray-modal', 'outline-gray-modals',
580
+ ]
581
+ const POST_MIGRATION_TOKENS = [
582
+ 'surface-base', 'ink-base', 'outline-base', 'surface-sidebar',
583
+ 'surface-elevation-1', 'surface-elevation-2', 'surface-elevation-3',
584
+ 'outline-elevation-2',
585
+ ]
586
+ const sentinelRegex = (tokens) =>
587
+ new RegExp(`(?<![a-zA-Z0-9])(?:${tokens.slice().sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`, 'g')
588
+ const PRE_REGEX = sentinelRegex(PRE_MIGRATION_TOKENS)
589
+ const POST_REGEX = sentinelRegex(POST_MIGRATION_TOKENS)
590
+
591
+ export function detectMigrationState(files) {
592
+ let pre = 0
593
+ let post = 0
594
+ for (const file of files) {
595
+ const content = fs.readFileSync(file, 'utf8')
596
+ pre += (content.match(PRE_REGEX) || []).length
597
+ post += (content.match(POST_REGEX) || []).length
598
+ }
599
+ return { pre, post, likelyMigrated: post > 0 }
600
+ }
601
+
602
+ export function getMigrationMode(
603
+ { likelyMigrated },
604
+ { force = false, radiusOnly = false, inkShift = false } = {},
605
+ ) {
606
+ if (inkShift) return 'ink-shift'
607
+ if (radiusOnly) return 'radius-only'
608
+ return likelyMigrated && !force ? 'migrated-typography' : 'full'
609
+ }
610
+
611
+ const RENAMES_BY_MODE = {
612
+ full: TOKEN_RENAMES,
613
+ 'migrated-typography': MIGRATED_MODE_RENAMES,
614
+ 'radius-only': RADIUS_RENAMES,
615
+ 'ink-shift': INK_SHIFT_RENAMES,
616
+ }
617
+
618
+ export function migrateTokens(content, { mode = 'full' } = {}) {
619
+ const tokenRenames = RENAMES_BY_MODE[mode]
620
+ const renameRegex = renameRegexFor(tokenRenames)
621
+ const replacements = []
622
+ let migrated = content.replace(renameRegex, (match, _token, offset) => {
623
+ const to = tokenRenames[match]
624
+ replacements.push({ from: match, to, line: lineAt(content, offset) })
625
+ return to
626
+ })
627
+
628
+ // Bare `rounded` → `rounded-4`, class contexts only (see isClassContext).
629
+ // Not in ink-shift mode — that mode runs ONLY the ink scale shift.
630
+ if (mode !== 'ink-shift') {
631
+ migrated = migrated.replace(BARE_ROUNDED_REGEX, (match, offset) => {
632
+ if (!isClassContext(migrated, offset)) return match
633
+ const to = `rounded-${RADIUS_BARE_STEP}`
634
+ replacements.push({ from: match, to, line: lineAt(migrated, offset) })
635
+ return to
636
+ })
637
+ }
638
+
639
+ let merges = []
640
+ const flagged = []
641
+ if (mode === 'full') {
642
+ // Merge weight classes on the post-rename text so `text-xl font-medium`
643
+ // becomes `text-2xl-medium` (size shift first, then merge).
644
+ const result = mergeWeightClasses(migrated)
645
+ migrated = result.migrated
646
+ merges = result.merges
647
+ flagged.push(...result.flagged)
648
+ }
649
+
650
+ for (const m of content.matchAll(flagRegexFor(mode))) {
651
+ flagged.push({ token: m[0], line: lineAt(content, m.index) })
652
+ }
653
+
654
+ return { migrated, replacements, merges, flagged }
655
+ }
656
+
657
+ function lineAt(content, offset) {
658
+ let line = 1
659
+ for (let i = 0; i < offset; i++) if (content[i] === '\n') line++
660
+ return line
661
+ }
662
+
663
+ // ---------- CLI ----------
664
+
665
+ const EXTENSIONS = new Set([
666
+ '.vue', '.ts', '.tsx', '.js', '.jsx', '.md', '.css', '.scss', '.html',
667
+ ])
668
+ const SKIP_DIRS = new Set([
669
+ 'node_modules', '.git', 'dist', 'cache', 'generated', 'espresso-v2-design-tokens',
670
+ ])
671
+
672
+ // Symlinks — directories and files alike — are followed only while their real
673
+ // path stays inside one of the run's target subtrees. A link to an external
674
+ // package is skipped and reported instead of rewritten: rewriting it would
675
+ // leave it without a run-once marker of its own, and a later direct
676
+ // --ink-shift run on that package would double-shift it. `ctx.seenDirs` is the
677
+ // cycle guard — an internal link back to an ancestor must not recurse forever.
678
+ function makeWalkContext(targets) {
679
+ return {
680
+ roots: targets.map((t) => fs.realpathSync(t)),
681
+ seenDirs: new Set(),
682
+ externals: [],
683
+ }
684
+ }
685
+
686
+ function* walk(target, ctx = makeWalkContext([target])) {
687
+ const stat = fs.statSync(target)
688
+ if (stat.isFile()) {
689
+ yield target
690
+ return
691
+ }
692
+ const real = fs.realpathSync(target)
693
+ if (ctx.seenDirs.has(real)) return
694
+ ctx.seenDirs.add(real)
695
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
696
+ const full = path.join(target, entry.name)
697
+ let isDirectory = entry.isDirectory()
698
+ if (entry.isSymbolicLink()) {
699
+ let real
700
+ try {
701
+ real = fs.realpathSync(full)
702
+ isDirectory = fs.statSync(full).isDirectory()
703
+ } catch {
704
+ continue // broken symlink
705
+ }
706
+ // Only links the run would otherwise rewrite are worth reporting.
707
+ const wouldRewrite = isDirectory
708
+ ? !SKIP_DIRS.has(entry.name)
709
+ : EXTENSIONS.has(path.extname(entry.name))
710
+ if (wouldRewrite && !isInsideRoots(real, ctx.roots)) {
711
+ ctx.externals.push(full)
712
+ continue
713
+ }
714
+ }
715
+ if (isDirectory) {
716
+ if (!SKIP_DIRS.has(entry.name)) yield* walk(full, ctx)
717
+ } else if (EXTENSIONS.has(path.extname(entry.name))) {
718
+ yield full
719
+ }
720
+ }
721
+ }
722
+
723
+ function main() {
724
+ const args = process.argv.slice(2)
725
+ const help = args.includes('--help') || args.includes('-h')
726
+ const dryRun = args.includes('--dry-run')
727
+ const force = args.includes('--force')
728
+ const radiusOnly = args.includes('--radius-only')
729
+ const inkShift = args.includes('--ink-shift')
730
+ const targets = args.filter((a) => !a.startsWith('--'))
731
+
732
+ if (help) {
733
+ console.log(USAGE)
734
+ return
735
+ }
736
+
737
+ // --ink-shift is a separate run-once pass — mixing it with the v1→v2
738
+ // migration flags would hide which renames ran.
739
+ if (inkShift && (force || radiusOnly)) {
740
+ console.error('--ink-shift cannot be combined with --force or --radius-only.')
741
+ console.error(USAGE)
742
+ process.exit(1)
743
+ }
744
+
745
+ if (targets.length === 0) {
746
+ console.error(USAGE)
747
+ process.exit(1)
748
+ }
749
+
750
+ // Dedupe by real path: overlapping targets (`src src/components`) or a
751
+ // symlink alias of one must not process a shared file once per target — in
752
+ // ink-shift mode a second pass is a double-shift.
753
+ const files = []
754
+ const seenFiles = new Set()
755
+ const walkCtx = makeWalkContext(targets)
756
+ for (const target of targets) {
757
+ for (const file of walk(target, walkCtx)) {
758
+ const resolved = fs.realpathSync(file)
759
+ if (seenFiles.has(resolved)) continue
760
+ seenFiles.add(resolved)
761
+ files.push(file)
762
+ }
763
+ }
764
+
765
+ // Guard against a destructive second full pass (the color renames reuse names).
766
+ const { pre, post, likelyMigrated } = detectMigrationState(files)
767
+ const mode = getMigrationMode({ likelyMigrated }, { force, radiusOnly, inkShift })
768
+ // realpath so a symlink alias and its target share one identity.
769
+ const inkShiftMarkerDirs =
770
+ mode === 'ink-shift' ? [...new Set(targets.map((t) => fs.realpathSync(t)))] : []
771
+ if (mode === 'ink-shift') {
772
+ // The marker records "this subtree shifted" — a file target would make it
773
+ // over-claim the whole directory, so only directory targets are allowed.
774
+ const fileTarget = inkShiftMarkerDirs.find((t) => !fs.statSync(t).isDirectory())
775
+ if (fileTarget) {
776
+ console.error(`\n✗ --ink-shift takes directory targets only, got a file: ${fileTarget}`)
777
+ console.error(` The ${INK_SHIFT_MARKER} run-once marker guards a directory subtree.\n`)
778
+ process.exit(1)
779
+ }
780
+ const marker =
781
+ inkShiftMarkerDirs.map((d) => findInkShiftMarker(d)).find(Boolean) ||
782
+ inkShiftMarkerDirs
783
+ .map((d) => findInkShiftMarkerBelow(d, { roots: inkShiftMarkerDirs }))
784
+ .find(Boolean)
785
+ // Both branches print this. Keep one source and let each pass its writer;
786
+ // two copies drift.
787
+ const printVendoredHint = (write) => {
788
+ write(' If the marker sits in a vendored copy outside node_modules, targeting')
789
+ write(` any ancestor of it finds it again — target directories that do not contain ${marker}.\n`)
790
+ }
791
+ if (marker && !dryRun) {
792
+ console.error(`\n✗ ${marker} found: --ink-shift already ran on this target.`)
793
+ console.error(' A second run would double-shift every chromatic ink token.')
794
+ console.error(' Delete the marker only to re-run the shift on purpose.')
795
+ printVendoredHint(console.error)
796
+ process.exit(1)
797
+ }
798
+ if (marker) {
799
+ console.warn(`\n⚠ ${marker} found: --ink-shift already ran on this target.`)
800
+ console.warn(' A real run would double-shift and will refuse to start.')
801
+ printVendoredHint(console.warn)
802
+ } else {
803
+ console.warn('\n⚠ Ink scale shift (#1016): this must run exactly once per codebase.')
804
+ console.warn(
805
+ dryRun
806
+ ? ` A real run writes a ${INK_SHIFT_MARKER} marker file in each target directory; --dry-run writes nothing.\n`
807
+ : ` A ${INK_SHIFT_MARKER} marker file in each target directory will record this run.\n`,
808
+ )
809
+ }
810
+ }
811
+ if (likelyMigrated && !radiusOnly && !inkShift) {
812
+ console.warn('\n⚠ This codebase looks already or partially migrated to espresso v2.')
813
+ console.warn(` Found ${post} v2-only token(s) and ${pre} pre-v2 token(s).`)
814
+ if (force) {
815
+ console.warn(' --force set: running the full migration anyway. Color tokens may double-shift.\n')
816
+ } else {
817
+ if (pre > 0) {
818
+ console.warn(` ${pre} pre-v2 color token(s) will be left untouched.`)
819
+ console.warn(' Pass --force to run the color migration too. Review carefully: color tokens may double-shift.')
820
+ }
821
+ console.warn(' Running only the typography correction (`text-lg` → `text-md`, ...) and the radius renames.')
822
+ console.warn(' Already ran the typography correction too? Use --radius-only instead.\n')
823
+ }
824
+ }
825
+
826
+ // Write the markers BEFORE any file rewrite so an interrupted run fails
827
+ // closed: the retry refuses instead of double-shifting the files that were
828
+ // already rewritten. Recovery: restore with git, delete the marker, re-run.
829
+ if (mode === 'ink-shift' && !dryRun) {
830
+ // All or nothing: a marker write that fails leaves no rewrite behind it,
831
+ // so the markers already written must go too. Keeping them would refuse a
832
+ // retry on targets that never shifted.
833
+ const written = []
834
+ const rollBack = () => {
835
+ for (const file of written) {
836
+ try {
837
+ fs.unlinkSync(file)
838
+ } catch {
839
+ console.error(` Could not remove ${file} — delete it before you re-run.`)
840
+ }
841
+ }
842
+ }
843
+ try {
844
+ for (const dir of inkShiftMarkerDirs) {
845
+ writeInkShiftMarker(dir)
846
+ written.push(path.join(dir, INK_SHIFT_MARKER))
847
+ }
848
+ } catch (err) {
849
+ rollBack()
850
+ if (err.code === 'EEXIST') {
851
+ console.error(`\n✗ Another --ink-shift run claimed ${err.path} first.`)
852
+ console.error(' No file was rewritten here. Let that run finish.\n')
853
+ } else {
854
+ console.error(`\n✗ Could not write the ${INK_SHIFT_MARKER} marker: ${err.message}`)
855
+ console.error(' No file was rewritten. Fix the permission and re-run.\n')
856
+ }
857
+ process.exit(1)
858
+ }
859
+
860
+ // Claim, then verify. An exclusive create only serialises runs on the SAME
861
+ // directory. Nested targets — a repo root against one of its
862
+ // subdirectories — claim different directories, so both could pass the
863
+ // first check. Searching again after the claim finds the other run's
864
+ // marker, which the first check could not have seen. Both runs may abort;
865
+ // that is the safe outcome, because neither has rewritten a file yet.
866
+ const ours = new Set(written)
867
+ const rival =
868
+ inkShiftMarkerDirs.map((d) => findInkShiftMarker(d, { ignore: ours })).find(Boolean) ||
869
+ inkShiftMarkerDirs
870
+ .map((d) => findInkShiftMarkerBelow(d, { roots: inkShiftMarkerDirs, ignore: ours }))
871
+ .find(Boolean)
872
+ if (rival) {
873
+ rollBack()
874
+ console.error(`\n✗ ${rival} appeared while this run was claiming its targets.`)
875
+ console.error(' Another --ink-shift run overlaps this one. No file was rewritten.')
876
+ console.error(' Let it finish, then re-run only what is still unshifted.\n')
877
+ process.exit(1)
878
+ }
879
+
880
+ console.log(
881
+ `Wrote ${INK_SHIFT_MARKER} in ${inkShiftMarkerDirs.join(', ')} — it blocks an accidental second run.\n`,
882
+ )
883
+ }
884
+
885
+ let filesChanged = 0
886
+ let totalReplacements = 0
887
+ let totalMerges = 0
888
+ const allFlagged = []
889
+
890
+ for (const file of files) {
891
+ const content = fs.readFileSync(file, 'utf8')
892
+ const { migrated, replacements, merges, flagged } = migrateTokens(content, { mode })
893
+
894
+ for (const f of flagged) allFlagged.push({ file, ...f })
895
+ const changeCount = replacements.length + merges.length
896
+ if (changeCount === 0) continue
897
+
898
+ filesChanged++
899
+ totalReplacements += replacements.length
900
+ totalMerges += merges.length
901
+ if (dryRun) {
902
+ console.log(`${file} (${changeCount})`)
903
+ for (const r of replacements) console.log(` L${r.line}: ${r.from} -> ${r.to}`)
904
+ for (const m of merges) console.log(` L${m.line}: ${m.from} => ${m.to}`)
905
+ } else {
906
+ fs.writeFileSync(file, migrated)
907
+ console.log(`${file} (${changeCount})`)
908
+ }
909
+ }
910
+
911
+ console.log(
912
+ `\n${dryRun ? '[dry-run] would update' : 'Updated'} ${filesChanged} files, ` +
913
+ `${totalReplacements} token renames, ${totalMerges} weight-class merges` +
914
+ (mode === 'migrated-typography' ? ' (typography correction + radius renames)' : '') +
915
+ (mode === 'radius-only' ? ' (radius renames only)' : '') +
916
+ (mode === 'ink-shift' ? ' (ink scale shift only)' : ''),
917
+ )
918
+
919
+ if (allFlagged.length > 0) {
920
+ console.log('\n⚠ Tokens needing manual attention (removed in v2 or unmapped):')
921
+ for (const f of allFlagged) {
922
+ console.log(` ${f.file}:L${f.line} ${f.token}`)
923
+ }
924
+ }
925
+
926
+ if (walkCtx.externals.length > 0) {
927
+ console.log('\n⚠ Symlinks to external packages were NOT migrated:')
928
+ for (const link of walkCtx.externals) {
929
+ console.log(` ${link} -> ${fs.realpathSync(link)}`)
930
+ }
931
+ console.log(' Run the codemod on each real package root directly.')
932
+ }
933
+ }
934
+
935
+ const scriptPath = fileURLToPath(import.meta.url)
936
+ const invokedPath = process.argv[1]
937
+ const isCLI = invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
938
+ if (isCLI) main()