@lablup/ui-common-cli 0.2.0-alpha.15

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,776 @@
1
+ /**
2
+ * `ui-common upgrade`: run the registered codemod steps between two ui-common
3
+ * versions over a consumer's source, edit its package.json, and write a
4
+ * manual-review report.
5
+ *
6
+ * All edits are made in memory first. `--dry-run` stops there and prints what
7
+ * would change and the report, writing nothing (the report only to an
8
+ * explicit `--report <path>`); otherwise the files and the report are
9
+ * written. A file is written only if this run read it, or created it where
10
+ * nothing existed.
11
+ */
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { spawnSync } from "node:child_process";
21
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
22
+
23
+ import { registeredVersions, stepsBetween } from "../codemods/registry.mjs";
24
+ import { TODO_TAG } from "../codemods/lib/jsx.mjs";
25
+ import { diffStat, unifiedDiff } from "./diff.mjs";
26
+ import { cliPackageJson, findProjectDir } from "./paths.mjs";
27
+ import { createResolver } from "./resolve.mjs";
28
+ import { renderReport, REPORT_HEADING } from "./report.mjs";
29
+ import { coerce, compare, parse } from "./semver.mjs";
30
+
31
+ const IGNORED_DIRS = new Set([
32
+ "node_modules",
33
+ ".git",
34
+ "dist",
35
+ "build",
36
+ "out",
37
+ ".next",
38
+ "coverage",
39
+ ".turbo",
40
+ ".cache",
41
+ // Only skipped by the project-wide finding scan (collectProjectFiles):
42
+ // build output and tool state that is never the project's own source.
43
+ "target",
44
+ ".venv",
45
+ "venv",
46
+ "__pycache__",
47
+ "storybook-static",
48
+ "playwright-report",
49
+ "test-results",
50
+ ".svelte-kit",
51
+ ".nuxt",
52
+ ".output",
53
+ ".vite",
54
+ ".yarn",
55
+ ".pnpm-store",
56
+ ]);
57
+
58
+ /** Bigger than any hand-written source; a bundle or a generated file. */
59
+ const MAX_SCAN_BYTES = 512 * 1024;
60
+
61
+ export const SOURCE_EXTENSIONS = new Set([
62
+ ".tsx",
63
+ ".ts",
64
+ ".jsx",
65
+ ".js",
66
+ ".mjs",
67
+ ".cjs",
68
+ ".mts",
69
+ ".cts",
70
+ ".css",
71
+ ".scss",
72
+ ".sass",
73
+ ".less",
74
+ ]);
75
+
76
+ /**
77
+ * @param {string} dir
78
+ * @param {string[]} out
79
+ */
80
+ function walk(dir, out) {
81
+ let entries;
82
+ try {
83
+ entries = readdirSync(dir, { withFileTypes: true });
84
+ } catch {
85
+ return;
86
+ }
87
+ for (const entry of entries) {
88
+ if (entry.isSymbolicLink()) continue;
89
+ const full = join(dir, entry.name);
90
+ if (entry.isDirectory()) {
91
+ if (!IGNORED_DIRS.has(entry.name)) walk(full, out);
92
+ } else if (
93
+ SOURCE_EXTENSIONS.has(extname(entry.name)) &&
94
+ !entry.name.endsWith(".d.ts")
95
+ ) {
96
+ out.push(full);
97
+ }
98
+ }
99
+ }
100
+
101
+ /** @param {string[]} paths absolute */
102
+ export function collectFiles(paths) {
103
+ /** @type {string[]} */
104
+ const out = [];
105
+ for (const p of paths) {
106
+ if (!existsSync(p)) continue;
107
+ if (readdirSafe(p)) walk(p, out);
108
+ else if (SOURCE_EXTENSIONS.has(extname(p))) out.push(p);
109
+ }
110
+ return [...new Set(out)].sort();
111
+ }
112
+
113
+ /** @param {string} p */
114
+ function readdirSafe(p) {
115
+ try {
116
+ readdirSync(p);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The files the manual-review scan reads: every source-like file under
125
+ * `roots`, without build output, generated directories, bundles, and
126
+ * packages nested in the project (another package.json below it is another
127
+ * project). In a git checkout the list comes from git, so .gitignore'd files
128
+ * are skipped too.
129
+ *
130
+ * @param {string[]} roots absolute
131
+ * @param {string} projectDir
132
+ */
133
+ export function collectProjectFiles(roots, projectDir) {
134
+ /** @type {string[]} */
135
+ let listed = [];
136
+ const git = spawnSync(
137
+ "git",
138
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."],
139
+ { cwd: projectDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
140
+ );
141
+ if (git.status === 0 && git.stdout) {
142
+ listed = git.stdout
143
+ .split("\0")
144
+ .filter(Boolean)
145
+ .map((f) => join(projectDir, f));
146
+ } else {
147
+ walk(projectDir, listed);
148
+ // walk keeps sources only; package.json files mark nested packages.
149
+ listed.push(...findPackageJsons(projectDir));
150
+ }
151
+ const nested = new Set(
152
+ listed
153
+ .filter((f) => f.endsWith(`${sep}package.json`) && dirname(f) !== projectDir)
154
+ .map((f) => dirname(f) + sep),
155
+ );
156
+ const inRoots = (/** @type {string} */ f) =>
157
+ roots.some((r) => f === r || f.startsWith(r.endsWith(sep) ? r : r + sep));
158
+ return [
159
+ ...new Set(
160
+ listed.filter((f) => {
161
+ if (!inRoots(f)) return false;
162
+ if (!SOURCE_EXTENSIONS.has(extname(f)) || f.endsWith(".d.ts")) return false;
163
+ if (/\.min\.[cm]?[jt]s$|\.min\.css$/.test(f)) return false;
164
+ const parts = relative(projectDir, f).split(sep);
165
+ if (parts.slice(0, -1).some((d) => IGNORED_DIRS.has(d))) return false;
166
+ for (const dir of nested) if (f.startsWith(dir)) return false;
167
+ try {
168
+ return statSync(f).size <= MAX_SCAN_BYTES;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }),
173
+ ),
174
+ ].sort();
175
+ }
176
+
177
+ /** @param {string} dir */
178
+ function findPackageJsons(dir) {
179
+ /** @type {string[]} */
180
+ const out = [];
181
+ const visit = (/** @type {string} */ d) => {
182
+ let entries;
183
+ try {
184
+ entries = readdirSync(d, { withFileTypes: true });
185
+ } catch {
186
+ return;
187
+ }
188
+ for (const entry of entries) {
189
+ if (entry.isSymbolicLink()) continue;
190
+ const full = join(d, entry.name);
191
+ if (entry.isDirectory()) {
192
+ if (!IGNORED_DIRS.has(entry.name)) visit(full);
193
+ } else if (entry.name === "package.json") out.push(full);
194
+ }
195
+ };
196
+ visit(dir);
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * @typedef {object} UpgradeOptions
202
+ * @property {string} cwd
203
+ * @property {string[]} paths as given (relative to cwd)
204
+ * @property {string[]} [scan] where the manual-review scan looks (relative to
205
+ * cwd); default: the whole project
206
+ * @property {string} [from]
207
+ * @property {string} [to]
208
+ * @property {boolean} [dryRun]
209
+ * @property {string} [report] report path
210
+ * @property {boolean} [diff] print unified diffs in a dry run
211
+ * @property {(line: string) => void} [log]
212
+ * @property {(line: string) => void} [warn]
213
+ */
214
+
215
+ /**
216
+ * @param {UpgradeOptions} options
217
+ */
218
+ export async function runUpgrade(options) {
219
+ const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
220
+ const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
221
+ const cwd = resolve(options.cwd);
222
+ const projectDir = findProjectDir(cwd) ?? cwd;
223
+ const pkgFile = join(projectDir, "package.json");
224
+ const pkgText = existsSync(pkgFile) ? readFileSync(pkgFile, "utf8") : null;
225
+ const pkg = pkgText ? JSON.parse(pkgText) : {};
226
+ // The codemods ship with the CLI, which moves in lockstep with ui-common:
227
+ // its version is the ui-common version they upgrade to.
228
+ const own = cliPackageJson();
229
+
230
+ const declared =
231
+ pkg.dependencies?.["@lablup/ui-common"] ??
232
+ pkg.devDependencies?.["@lablup/ui-common"] ??
233
+ pkg.peerDependencies?.["@lablup/ui-common"];
234
+ const from = options.from ? coerce(options.from) : declared ? coerce(declared) : null;
235
+ if (!from) {
236
+ warn(
237
+ options.from
238
+ ? `ui-common upgrade: --from "${options.from}" is not a version.`
239
+ : `ui-common upgrade: cannot tell which ui-common version this project is on (package.json: ${declared ?? "no @lablup/ui-common"}). Pass --from <version>.`,
240
+ );
241
+ return { code: 2 };
242
+ }
243
+ const to = options.to ? coerce(options.to) : own.version;
244
+ if (!to || !parse(to)) {
245
+ warn(`ui-common upgrade: --to "${options.to}" is not a version.`);
246
+ return { code: 2 };
247
+ }
248
+ if (compare(from, to) >= 0) {
249
+ log(`ui-common upgrade: ${from} → ${to} is not an upgrade; nothing to do.`);
250
+ return { code: 0 };
251
+ }
252
+
253
+ const steps = await stepsBetween(from, to);
254
+ if (steps.length === 0) {
255
+ log(
256
+ `ui-common upgrade: no codemods between ${from} and ${to} (registered: ${registeredVersions().join(", ")}).`,
257
+ );
258
+ return { code: 0 };
259
+ }
260
+
261
+ const roots = (options.paths.length > 0 ? options.paths : ["src"]).map((p) =>
262
+ resolve(cwd, p),
263
+ );
264
+ const missing = roots.filter((p) => !existsSync(p));
265
+ if (missing.length === roots.length) {
266
+ warn(
267
+ `ui-common upgrade: nothing to scan: ${missing.map((p) => relative(cwd, p) || ".").join(", ")} not found.`,
268
+ );
269
+ return { code: 2 };
270
+ }
271
+ const files = collectFiles(roots);
272
+ const scanRoots =
273
+ options.scan && options.scan.length > 0
274
+ ? options.scan.map((p) => resolve(cwd, p))
275
+ : [projectDir];
276
+
277
+ // A dry run prints the report unless --report names a file for it. The
278
+ // report replaces an earlier report, never anything else.
279
+ const writeReport = !options.dryRun || options.report != null;
280
+ const reportFile = resolve(cwd, options.report ?? "ui-common-upgrade-report.md");
281
+ if (
282
+ writeReport &&
283
+ existsSync(reportFile) &&
284
+ !readFileSync(reportFile, "utf8").startsWith(REPORT_HEADING)
285
+ ) {
286
+ warn(
287
+ `ui-common upgrade: ${relative(cwd, reportFile)} exists and is not an upgrade report; not overwriting it. Pass --report <path>.`,
288
+ );
289
+ return { code: 2 };
290
+ }
291
+
292
+ const { default: jscodeshift } = await import("jscodeshift");
293
+
294
+ /** @type {Map<string, {original: string, current: string, transforms: string[], created: boolean, project?: boolean}>} */
295
+ const state = new Map();
296
+ for (const file of files) {
297
+ const source = readFileSync(file, "utf8");
298
+ state.set(file, {
299
+ original: source,
300
+ current: source,
301
+ transforms: [],
302
+ created: false,
303
+ });
304
+ }
305
+ const rel = (/** @type {string} */ file) =>
306
+ relative(projectDir, file).split(sep).join("/");
307
+
308
+ /** @type {string[]} */
309
+ const packageNotes = [];
310
+ /** @type {Array<{file: string, transform: string, error: string}>} */
311
+ const errors = [];
312
+ /** @type {string[]} */
313
+ const notices = [];
314
+ /** @type {string[]} */
315
+ const alerts = [];
316
+ const resolver = createResolver(projectDir);
317
+ /** @type {Map<string, string | null>} */
318
+ const unscanned = new Map();
319
+ const ctx = {
320
+ from,
321
+ to,
322
+ flags: { packages: new Map(), touched: new Set() },
323
+ projectDir,
324
+ note: (/** @type {string} */ message) => packageNotes.push(message),
325
+ /**
326
+ * A project file as it was before this run: the scanned sources from
327
+ * memory, anything else from disk (read once). Null outside the project
328
+ * or when unreadable.
329
+ *
330
+ * @param {string} path absolute
331
+ */
332
+ source: (path) => {
333
+ const known = state.get(path);
334
+ if (known) return known.created ? null : known.original;
335
+ if (
336
+ !path.startsWith(projectDir + sep) ||
337
+ path.split(sep).includes("node_modules")
338
+ )
339
+ return null;
340
+ if (!unscanned.has(path)) {
341
+ let text = null;
342
+ try {
343
+ text = readFileSync(path, "utf8");
344
+ } catch {
345
+ // left null
346
+ }
347
+ unscanned.set(path, text);
348
+ }
349
+ return unscanned.get(path) ?? null;
350
+ },
351
+ /** The project file an import specifier names (relative or a tsconfig alias), or null. */
352
+ resolveImport: resolver.resolveImport,
353
+ /** The project's package.json, parsed, as it was before this run. */
354
+ pkg,
355
+ /**
356
+ * Something the person has to do before the upgraded app works: the
357
+ * report opens with these.
358
+ *
359
+ * @param {string} message
360
+ */
361
+ alert: (message) => alerts.push(message),
362
+ /** @param {string} message where the run did something other than the usual */
363
+ notice: (message) => notices.push(message),
364
+ /** Every file the finding scan would read (the whole project), absolute. */
365
+ projectFiles: () => collectProjectFiles([projectDir], projectDir),
366
+ /**
367
+ * A file's content as this run has it now (edits included), or from disk.
368
+ *
369
+ * @param {string} path absolute
370
+ */
371
+ current: (path) => {
372
+ const known = state.get(path);
373
+ if (known) return known.current;
374
+ try {
375
+ return readFileSync(path, "utf8");
376
+ } catch {
377
+ return null;
378
+ }
379
+ },
380
+ /**
381
+ * Edit a project file outside the scanned sources (pnpm-workspace.yaml).
382
+ * `edit` gets its current text (null: absent) and returns the new text,
383
+ * or undefined to leave it. The write goes through the same checks, diff
384
+ * and dry run as the sources.
385
+ *
386
+ * @param {string} path
387
+ * @param {(current: string | null) => string | undefined} edit
388
+ * @param {string} [id] the transform named in the report
389
+ */
390
+ editFile: (path, edit, id = "package-json") => {
391
+ const known = state.get(path);
392
+ const current = known
393
+ ? known.current
394
+ : existsSync(path)
395
+ ? readFileSync(path, "utf8")
396
+ : null;
397
+ const next = edit(current);
398
+ if (next == null || next === current) return;
399
+ if (known) {
400
+ known.current = next;
401
+ if (!known.transforms.includes(id)) known.transforms.push(id);
402
+ return;
403
+ }
404
+ state.set(path, {
405
+ original: current ?? "",
406
+ current: next,
407
+ transforms: [id],
408
+ created: current == null,
409
+ project: true,
410
+ });
411
+ },
412
+ /**
413
+ * Claim a new file and return the path it will be written to. A file the
414
+ * project already has, scanned or not, is never overwritten: one holding
415
+ * exactly `content` is reused, otherwise the next free numbered sibling
416
+ * (`ui-common-entry-2.css`) is used and the report says so.
417
+ *
418
+ * @param {string} path
419
+ * @param {string} content
420
+ */
421
+ createFile: (path, content) => {
422
+ const ext = extname(path);
423
+ const stem = path.slice(0, path.length - ext.length);
424
+ for (let n = 1; ; n++) {
425
+ const candidate = n === 1 ? path : `${stem}-${n}${ext}`;
426
+ const known = state.get(candidate);
427
+ const onDisk =
428
+ known?.current ??
429
+ (existsSync(candidate) ? readFileSync(candidate, "utf8") : null);
430
+ if (onDisk === content) return candidate;
431
+ if (onDisk != null) continue;
432
+ if (n > 1 && !notices.some((m) => m.startsWith(`${rel(path)} `))) {
433
+ notices.push(
434
+ `${rel(path)} exists already and is not the 0.2 stylesheet entry, so it was left alone. The entry was written to ${rel(candidate)} instead, and the script that imported styles/base.css imports it.`,
435
+ );
436
+ }
437
+ state.set(candidate, {
438
+ original: "",
439
+ current: content,
440
+ transforms: ["stylesheet-entry"],
441
+ created: true,
442
+ });
443
+ return candidate;
444
+ }
445
+ },
446
+ };
447
+
448
+ for (const { version, step } of steps) {
449
+ log(`Applying ${step.title} (${version})`);
450
+ for (const transform of step.transforms) {
451
+ const extensions = new Set(transform.extensions);
452
+ for (const [file, entry] of state) {
453
+ if (entry.created || !extensions.has(extname(file))) continue;
454
+ const ext = extname(file);
455
+ const j = jscodeshift.withParser(/\.[cm]?tsx?$/.test(ext) ? "tsx" : "babel");
456
+ let out;
457
+ try {
458
+ out = transform.run(
459
+ { path: file, source: entry.current },
460
+ { jscodeshift: j },
461
+ ctx,
462
+ );
463
+ } catch (err) {
464
+ errors.push({
465
+ file: rel(file),
466
+ transform: transform.id,
467
+ error: /** @type {Error} */ (err).message,
468
+ });
469
+ continue;
470
+ }
471
+ if (out == null || out === entry.current) continue;
472
+ if (transform.parse) {
473
+ try {
474
+ j(out);
475
+ } catch (err) {
476
+ errors.push({
477
+ file: rel(file),
478
+ transform: transform.id,
479
+ error: `produced unparseable output, not applied: ${/** @type {Error} */ (err).message}`,
480
+ });
481
+ continue;
482
+ }
483
+ }
484
+ entry.current = out;
485
+ if (!entry.transforms.includes(transform.id))
486
+ entry.transforms.push(transform.id);
487
+ }
488
+ }
489
+ }
490
+
491
+ // Project-level edits that need every file transformed first.
492
+ for (const { step } of steps) {
493
+ if (!step.afterTransforms) continue;
494
+ try {
495
+ step.afterTransforms(ctx, { jscodeshift });
496
+ } catch (err) {
497
+ errors.push({
498
+ file: ".",
499
+ transform: "after-transforms",
500
+ error: /** @type {Error} */ (err).message,
501
+ });
502
+ }
503
+ }
504
+
505
+ // package.json, after every source transform has set its flags.
506
+ let pkgAfter = pkgText;
507
+ if (pkgText) {
508
+ for (const { step } of steps) {
509
+ if (!step.packageJson || pkgAfter == null) continue;
510
+ try {
511
+ pkgAfter = step.packageJson(pkgAfter, ctx) ?? pkgAfter;
512
+ } catch (err) {
513
+ errors.push({
514
+ file: "package.json",
515
+ transform: "package-json",
516
+ error: /** @type {Error} */ (err).message,
517
+ });
518
+ }
519
+ }
520
+ }
521
+
522
+ // Report-only scans, over the final content.
523
+ /** @type {Array<{category: string, file: string, line: number, text: string, detail?: string}>} */
524
+ const findings = [];
525
+ /** @type {Record<string, {title: string, help: string}>} */
526
+ const categories = {};
527
+ // The finding scan reads the whole project (or --scan), not only the
528
+ // transformed sources: tests, e2e specs and scripts name 0.1 classes and
529
+ // stylesheet paths too. Files outside the sources are read, never written.
530
+ /** @type {Array<[string, string]>} */
531
+ const scanned = [];
532
+ const inScan = (/** @type {string} */ f) =>
533
+ scanRoots.some((r) => f === r || f.startsWith(r + sep));
534
+ for (const [file, entry] of state) {
535
+ if (entry.created || entry.project || !inScan(file)) continue;
536
+ scanned.push([file, entry.current]);
537
+ }
538
+ for (const file of collectProjectFiles(scanRoots, projectDir)) {
539
+ if (state.has(file)) continue;
540
+ const text = ctx.source(file);
541
+ if (text != null) scanned.push([file, text]);
542
+ }
543
+ scanned.sort((a, b) => a[0].localeCompare(b[0]));
544
+ for (const { step } of steps) {
545
+ Object.assign(categories, step.categories ?? {});
546
+ if (step.findings) findings.push(...step.findings(ctx, rel));
547
+ if (!step.scan) continue;
548
+ const context = step.prepareScan?.(
549
+ scanned.map(([file, text]) => [rel(file), text]),
550
+ );
551
+ for (const [file, text] of scanned)
552
+ findings.push(...step.scan(rel(file), text, context));
553
+ }
554
+ /** @type {Array<{file: string, line: number, text: string}>} */
555
+ const todos = [];
556
+ let tokenReads = 0;
557
+ for (const [file, entry] of state) {
558
+ entry.current.split("\n").forEach((line, i) => {
559
+ if (line.includes(TODO_TAG)) {
560
+ todos.push({
561
+ file: rel(file),
562
+ line: i + 1,
563
+ text: line
564
+ .replace(/^.*TODO\(ui-common-upgrade\):\s*/, "")
565
+ .replace(/\s*\*\/\s*\}?\s*$/, "")
566
+ .trim(),
567
+ });
568
+ }
569
+ });
570
+ tokenReads += (entry.current.match(/var\(\s*--token-/g) ?? []).length;
571
+ }
572
+
573
+ const changed = [...state.entries()]
574
+ .filter(([, e]) => e.current !== e.original)
575
+ .map(([file, e]) => ({
576
+ file: rel(file),
577
+ abs: file,
578
+ ...e,
579
+ stat: diffStat(e.original, e.current),
580
+ }));
581
+ const pkgChanged = pkgText != null && pkgAfter != null && pkgAfter !== pkgText;
582
+
583
+ const dryRun = Boolean(options.dryRun);
584
+ if (!dryRun) {
585
+ for (const c of changed) {
586
+ // Only what this run read, or a file it claimed that is still absent.
587
+ const now = existsSync(c.abs) ? readFileSync(c.abs, "utf8") : null;
588
+ if (c.created ? now != null : now !== c.original) {
589
+ errors.push({
590
+ file: c.file,
591
+ transform: c.transforms.join(", "),
592
+ error: c.created
593
+ ? "appeared on disk during the run; left alone."
594
+ : "changed on disk during the run; left alone.",
595
+ });
596
+ continue;
597
+ }
598
+ mkdirSync(dirname(c.abs), { recursive: true });
599
+ writeFileSync(c.abs, c.current);
600
+ }
601
+ if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter);
602
+ }
603
+
604
+ if (resolver.unresolved.size > 0) {
605
+ const list = [...resolver.unresolved.entries()]
606
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
607
+ .map(([prefix, n]) => `\`${prefix}\` (${n})`);
608
+ notices.push(
609
+ `Imports through ${list.join(", ")} did not resolve: the upgrade reads relative imports and tsconfig \`paths\`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand.`,
610
+ );
611
+ }
612
+
613
+ const report = renderReport({
614
+ from,
615
+ to,
616
+ version: own.version,
617
+ dryRun,
618
+ roots: roots.map((r) => relative(projectDir, r).split(sep).join("/") || "."),
619
+ fileCount: files.length,
620
+ scanRoots: scanRoots.map(
621
+ (r) => relative(projectDir, r).split(sep).join("/") || ".",
622
+ ),
623
+ scanCount: scanned.length,
624
+ steps: steps.map(({ version, step }) => ({
625
+ version,
626
+ title: step.title,
627
+ notes: typeof step.notes === "function" ? step.notes(ctx) : (step.notes ?? []),
628
+ })),
629
+ changed: changed.map((c) => ({
630
+ file: c.file,
631
+ created: c.created,
632
+ transforms: c.transforms,
633
+ ...c.stat,
634
+ })),
635
+ packageJson: { changed: pkgChanged, notes: packageNotes },
636
+ todos,
637
+ findings,
638
+ categories,
639
+ errors,
640
+ notices,
641
+ alerts,
642
+ tokenReads,
643
+ });
644
+ if (writeReport) {
645
+ mkdirSync(dirname(reportFile), { recursive: true });
646
+ writeFileSync(reportFile, report);
647
+ }
648
+
649
+ // Terminal summary.
650
+ const verb = dryRun ? "Would change" : "Changed";
651
+ for (const c of changed) {
652
+ log(` ${c.created ? "+" : "~"} ${c.file} (+${c.stat.added} -${c.stat.removed})`);
653
+ }
654
+ if (pkgChanged) log(` ~ ${rel(pkgFile)}`);
655
+ log(
656
+ `${verb} ${changed.length} file${changed.length === 1 ? "" : "s"}${pkgChanged ? " and package.json" : ""}; ` +
657
+ `${todos.length} TODO marker${todos.length === 1 ? "" : "s"}, ${findings.length} manual-review item${findings.length === 1 ? "" : "s"}.`,
658
+ );
659
+ if (dryRun && options.diff) {
660
+ for (const c of changed) log(unifiedDiff(c.file, c.original, c.current));
661
+ if (pkgChanged && pkgText != null && pkgAfter != null)
662
+ log(unifiedDiff("package.json", pkgText, pkgAfter));
663
+ }
664
+ for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`);
665
+ for (const alert of alerts) warn(` ACTION REQUIRED: ${alert}`);
666
+ for (const notice of notices) warn(` note: ${notice}`);
667
+ if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`);
668
+ else log(`\n${report}`);
669
+ if (dryRun)
670
+ log(
671
+ `Dry run: nothing was written${writeReport ? " but the report" : ""}. Run without --dry-run to apply${writeReport ? "" : ", or pass --report <path> to keep the report"}.`,
672
+ );
673
+ if (!dryRun && pkgChanged)
674
+ log("package.json changed: run your package manager's install.");
675
+
676
+ return {
677
+ code: errors.length > 0 ? 1 : 0,
678
+ changed,
679
+ todos,
680
+ findings,
681
+ errors,
682
+ report,
683
+ pkgAfter,
684
+ };
685
+ }
686
+
687
+ export const UPGRADE_HELP = `Usage: ui-common upgrade [--from <version>] [--to <version>] [--dry-run] [--diff] [--report <path>] [--scan <path>]… [paths…]
688
+
689
+ Run the codemods registered between two @lablup/ui-common versions over your
690
+ source (TS/TSX/JS/JSX through jscodeshift, CSS through postcss), update
691
+ package.json, and write a manual-review report. From 0.1, package.json also
692
+ gains @lablup/ui-common-cli (this bin) as a devDependency at the new version.
693
+
694
+ One-off, from a project still on 0.1 (\`@next\` until 0.2.0 is published):
695
+ pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run
696
+ (npx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run)
697
+
698
+ --from <version> The ui-common version the code is written against.
699
+ Default: the version package.json declares.
700
+ --to <version> Default: this CLI's version, which is the @lablup/ui-common
701
+ version it ships with.
702
+ --dry-run Write nothing; list what would change and print the report.
703
+ --diff With --dry-run, also print unified diffs.
704
+ --report <path> Where to write the report. Default: ui-common-upgrade-report.md,
705
+ except in a dry run, which writes a report only to a path
706
+ given here. An existing file is replaced only if it is an
707
+ earlier report.
708
+ --scan <path> Where the report looks for 0.1 class names, stylesheet
709
+ paths, test queries and module mocks. Default: the whole
710
+ project (tests, e2e, scripts, …), without node_modules,
711
+ build output, git-ignored files and nested packages.
712
+ Repeat to narrow it: --scan src --scan e2e.
713
+ paths… Directories or files to transform. Default: src/
714
+
715
+ Exit codes: 0 done, 1 some files could not be transformed (see the report),
716
+ 2 bad arguments or nothing to scan.
717
+ `;
718
+
719
+ /**
720
+ * @param {string[]} argv arguments after `upgrade`
721
+ */
722
+ export async function upgradeCommand(argv) {
723
+ /** @type {UpgradeOptions} */
724
+ const options = { cwd: process.cwd(), paths: [] };
725
+ for (let i = 0; i < argv.length; i++) {
726
+ const arg = argv[i];
727
+ const [flag, inline] =
728
+ arg.startsWith("--") && arg.includes("=")
729
+ ? arg.split(/=(.*)/s)
730
+ : [arg, undefined];
731
+ const value = () => inline ?? argv[++i];
732
+ switch (flag) {
733
+ case "--from":
734
+ options.from = value();
735
+ break;
736
+ case "--to":
737
+ options.to = value();
738
+ break;
739
+ case "--report":
740
+ options.report = value();
741
+ break;
742
+ case "--scan": {
743
+ const v = value();
744
+ if (v) (options.scan ??= []).push(v);
745
+ break;
746
+ }
747
+ case "--dry-run":
748
+ options.dryRun = true;
749
+ break;
750
+ case "--diff":
751
+ options.diff = true;
752
+ break;
753
+ case "-h":
754
+ case "--help":
755
+ process.stdout.write(UPGRADE_HELP);
756
+ return 0;
757
+ default:
758
+ if (arg.startsWith("-")) {
759
+ process.stderr.write(
760
+ `ui-common upgrade: unknown option "${arg}"\n${UPGRADE_HELP}`,
761
+ );
762
+ return 2;
763
+ }
764
+ options.paths.push(arg);
765
+ }
766
+ if (["--from", "--to", "--report", "--scan"].includes(flag)) {
767
+ const v = /** @type {any} */ (options)[flag.slice(2)];
768
+ if (!v) {
769
+ process.stderr.write(`ui-common upgrade: ${flag} needs a value\n`);
770
+ return 2;
771
+ }
772
+ }
773
+ }
774
+ const result = await runUpgrade(options);
775
+ return result.code;
776
+ }