@hirobius/design-system 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +969 -0
  3. package/CONSUMING.md +23 -9
  4. package/MIGRATIONS.md +603 -0
  5. package/README.md +2 -0
  6. package/UPGRADING.md +222 -0
  7. package/codemods/hds-prefix.mjs +28 -13
  8. package/codemods/jsx-fold.mjs +28 -13
  9. package/codemods/lib/atomic-write.mjs +28 -0
  10. package/codemods/lib/installed-version.mjs +354 -0
  11. package/codemods/lib/project.mjs +395 -0
  12. package/codemods/lib/record.mjs +110 -0
  13. package/codemods/lib/scan.mjs +323 -0
  14. package/codemods/lib/semver.mjs +57 -0
  15. package/codemods/not-found-pattern.mjs +3 -3
  16. package/codemods/patterns-subpath.mjs +28 -12
  17. package/codemods/registry.mjs +67 -0
  18. package/codemods/tile-grid.mjs +3 -3
  19. package/codemods/upgrade.mjs +909 -0
  20. package/dist/hirobius-ui.js +1 -1
  21. package/dist/manifest.js +3 -3
  22. package/dist/styles.css +1 -1
  23. package/dist/tokens.css +1 -1
  24. package/dist/types/src/app/components/alert-dialog.d.ts +1 -1
  25. package/dist/types/src/app/components/alert.d.ts +1 -1
  26. package/dist/types/src/app/components/asset-img.d.ts +1 -1
  27. package/dist/types/src/app/components/avatar-group.d.ts +1 -1
  28. package/dist/types/src/app/components/avatar.d.ts +1 -1
  29. package/dist/types/src/app/components/badge.d.ts +1 -1
  30. package/dist/types/src/app/components/blockquote.d.ts +1 -1
  31. package/dist/types/src/app/components/breadcrumb.d.ts +1 -1
  32. package/dist/types/src/app/components/button.d.ts +1 -1
  33. package/dist/types/src/app/components/callout.d.ts +1 -1
  34. package/dist/types/src/app/components/card.d.ts +1 -1
  35. package/dist/types/src/app/components/checkbox.d.ts +1 -1
  36. package/dist/types/src/app/components/code-block.d.ts +1 -1
  37. package/dist/types/src/app/components/combobox.d.ts +1 -1
  38. package/dist/types/src/app/components/data-table-section.d.ts +1 -1
  39. package/dist/types/src/app/components/destructive-section.d.ts +1 -1
  40. package/dist/types/src/app/components/dialog.d.ts +1 -1
  41. package/dist/types/src/app/components/disclosure.d.ts +1 -1
  42. package/dist/types/src/app/components/divider.d.ts +1 -1
  43. package/dist/types/src/app/components/empty-state.d.ts +1 -1
  44. package/dist/types/src/app/components/field.d.ts +1 -1
  45. package/dist/types/src/app/components/form-actions.d.ts +1 -1
  46. package/dist/types/src/app/components/form.d.ts +1 -1
  47. package/dist/types/src/app/components/hds-tooltip.d.ts +1 -1
  48. package/dist/types/src/app/components/icon.d.ts +1 -1
  49. package/dist/types/src/app/components/inline-link.d.ts +1 -1
  50. package/dist/types/src/app/components/input.d.ts +1 -1
  51. package/dist/types/src/app/components/kbd.d.ts +1 -1
  52. package/dist/types/src/app/components/menu.d.ts +1 -1
  53. package/dist/types/src/app/components/metadata-list.d.ts +1 -1
  54. package/dist/types/src/app/components/metric-tiles.d.ts +1 -1
  55. package/dist/types/src/app/components/pagination.d.ts +1 -1
  56. package/dist/types/src/app/components/popover.d.ts +1 -1
  57. package/dist/types/src/app/components/progress.d.ts +1 -1
  58. package/dist/types/src/app/components/radio.d.ts +1 -1
  59. package/dist/types/src/app/components/select.d.ts +1 -1
  60. package/dist/types/src/app/components/skeleton.d.ts +1 -1
  61. package/dist/types/src/app/components/slider.d.ts +1 -1
  62. package/dist/types/src/app/components/spinner.d.ts +1 -1
  63. package/dist/types/src/app/components/stat.d.ts +1 -1
  64. package/dist/types/src/app/components/status-list-item.d.ts +1 -1
  65. package/dist/types/src/app/components/status-tile.d.ts +1 -1
  66. package/dist/types/src/app/components/table.d.ts +1 -1
  67. package/dist/types/src/app/components/tabs.d.ts +1 -1
  68. package/dist/types/src/app/components/tag.d.ts +1 -1
  69. package/dist/types/src/app/components/text.d.ts +1 -1
  70. package/dist/types/src/app/components/textarea.d.ts +1 -1
  71. package/dist/types/src/app/components/timestamp.d.ts +1 -1
  72. package/dist/types/src/app/components/toast.d.ts +1 -1
  73. package/dist/types/src/app/components/toggle.d.ts +1 -1
  74. package/dist/types/src/index.d.ts +79 -32
  75. package/hirobius.tokens.json +86 -39
  76. package/mcp/catalog.mjs +10 -7
  77. package/mcp/core-set.mjs +66 -0
  78. package/mcp/guide.mjs +9 -52
  79. package/package.json +23 -7
  80. package/public/hds-manifest.json +109 -109
  81. package/src/app/data/component-api.json +54 -54
  82. package/upgrade/index.json +208 -0
  83. package/upgrade/releases/0.17.0.json +962 -0
  84. package/upgrade/releases/0.18.0.json +8 -0
  85. package/upgrade/releases/0.19.0.json +233 -0
  86. package/upgrade/releases/0.19.1.json +8 -0
  87. package/upgrade/releases/0.20.0.json +5108 -0
  88. package/upgrade/releases/0.21.0.json +538 -0
  89. package/upgrade/releases/0.22.0.json +94 -0
  90. package/upgrade/schema.json +989 -0
@@ -0,0 +1,909 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * upgrade — one command from the installed @hirobius/design-system to a newer
4
+ * one (hds#452):
5
+ *
6
+ * npx @hirobius/design-system@latest upgrade [flags]
7
+ *
8
+ * npx runs the bin named after the package, `design-system`, which takes the
9
+ * subcommand; the `hds-upgrade` bin is the same file and runs it directly.
10
+ *
11
+ * The steps come from the upgrade record of the package this file ships in
12
+ * (upgrade/index.json and upgrade/releases/<version>.json, hds#451), never
13
+ * from the consumer's node_modules copy, so @latest always brings the newest
14
+ * steps. For every importer that declares the package (the root and each
15
+ * workspace package), it:
16
+ *
17
+ * 1. reads the installed version: the lockfile, then node_modules, then
18
+ * --from; an older lockfile at git HEAD or at the merge-base with the
19
+ * default branch wins (bumped by hand), and when the lockfile already
20
+ * says the target, its git history is read too (lib/project.mjs);
21
+ * 2. refuses, changing nothing (exit 2), a version below the record's floor,
22
+ * a downgrade, or a target the record has no ledger for;
23
+ * 3. applies the ledgers after the installed version up to the target,
24
+ * oldest first, plus any earlier step that takes a name away which the
25
+ * code still uses (a hand bump that skipped it): runs their codemods, bumps the dependency range (keeping
26
+ * its operator), and adds back a dependency the package stopped
27
+ * installing that the code still imports;
28
+ * 4. installs with the project's package manager and runs its typecheck
29
+ * script (or tsc --noEmit); a lockfile still short of the target after
30
+ * that is Do by hand, so the command never says done before an install;
31
+ * 5. prints four lists: Fixed for you, Looks different (only what the code
32
+ * uses), Coming next (deprecations, with the release that removes them)
33
+ * and Do by hand (what the code still uses and has not changed yet).
34
+ *
35
+ * --dry-run and --check run the codemods on a copy of the code in a tmp
36
+ * directory, so they list exactly what the run would.
37
+ *
38
+ * Exit 0: done, nothing left by hand. 1: work left. 2: refused or error.
39
+ * --json prints the report as JSON (upgrade/schema.json, $defs.upgradeReport);
40
+ * --report <file> writes that JSON to a file as well. Node builtins only.
41
+ */
42
+ import { spawnSync } from 'node:child_process';
43
+ import {
44
+ existsSync,
45
+ mkdirSync,
46
+ mkdtempSync,
47
+ readFileSync,
48
+ rmSync,
49
+ statSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import { tmpdir } from 'node:os';
53
+ import { basename, dirname, join, resolve } from 'node:path';
54
+ import { fileURLToPath } from 'node:url';
55
+ import { isEntry } from './jsx-fold.mjs';
56
+ import { writeFileAtomic } from './lib/atomic-write.mjs';
57
+ import { HDS_PACKAGE } from './lib/installed-version.mjs';
58
+ import {
59
+ detectManager,
60
+ enclosingWorkspace,
61
+ findImporters,
62
+ installedVersions,
63
+ nodeModulesVersion,
64
+ readRootLockfile,
65
+ } from './lib/project.mjs';
66
+ import { comingNextSteps, listOf, loadRecord } from './lib/record.mjs';
67
+ import { MAX_BYTES, collectFiles, fileMentions, matchDetect } from './lib/scan.mjs';
68
+ import { bumpRange, compareVersions, isVersion } from './lib/semver.mjs';
69
+ import { hasCodemod, runCodemod } from './registry.mjs';
70
+
71
+ const readJson = (file) => {
72
+ try {
73
+ return JSON.parse(readFileSync(file, 'utf8'));
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
80
+
81
+ export const USAGE = `usage: npx ${HDS_PACKAGE}@latest upgrade [flags]
82
+
83
+ --root <dir> the project to upgrade (default: the current directory)
84
+ --from <version> the installed version, when no lockfile or node_modules says it
85
+ --to <version> the version to upgrade to (default: this package's version)
86
+ --dry-run change nothing; print what would change
87
+ --check change nothing; exit 1 while anything is left to do
88
+ --json print the report as JSON
89
+ --report <file> also write the JSON report to a file
90
+ --no-install do not run the package manager's install
91
+ --no-typecheck do not run the typecheck script (or tsc --noEmit)
92
+ --help print this
93
+
94
+ Exit 0: done. 1: work left. 2: refused (nothing was changed) or an error.`;
95
+
96
+ const LISTS = ['fixedForYou', 'looksDifferent', 'comingNext', 'doByHand'];
97
+ const HEADINGS = {
98
+ fixedForYou: 'Fixed for you',
99
+ looksDifferent: 'Looks different',
100
+ comingNext: 'Coming next',
101
+ doByHand: 'Do by hand',
102
+ };
103
+ const SEVERITY = ['none', 'additive', 'look', 'behavior', 'breaking'];
104
+ const REMOVING = new Set(['removed', 'moved', 'renamed', 'folded']);
105
+
106
+ /** Kinds whose fix leaves what `detect` matches in place: a value or a behavior to check. */
107
+ const CHECK_ONLY = new Set(['value-changed', 'behavior']);
108
+
109
+ /**
110
+ * Whether a Do by hand step blocks until the code changes: it takes something
111
+ * away, is a manual step, says how to tell it is done, or breaks in a way the
112
+ * edit removes. A value or behavior change with no `done` detector is listed
113
+ * to check and never blocks, even when breaking: the code that handles it
114
+ * still matches its detect, so nothing could ever check it off.
115
+ */
116
+ const blocks = (step) =>
117
+ REMOVING.has(step.kind) ||
118
+ step.kind === 'manual' ||
119
+ !!step.done ||
120
+ (step.impact === 'breaking' && !CHECK_ONLY.has(step.kind));
121
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
122
+
123
+ /**
124
+ * @param {string[]} argv
125
+ * @returns {{ options?: any, help?: boolean, error?: string }}
126
+ */
127
+ export function parseArgs(argv, invokedAs = '') {
128
+ const args = [...argv];
129
+ if (args[0] === 'upgrade') args.shift();
130
+ else if (args[0] && !args[0].startsWith('-')) return { error: `unknown command: ${args[0]}` };
131
+ else if (/^design-system(\.cmd)?$/.test(invokedAs) && !args.includes('--help')) {
132
+ return { error: 'name the command: upgrade' };
133
+ }
134
+ const options = {
135
+ root: process.cwd(),
136
+ from: null,
137
+ to: null,
138
+ mode: 'apply',
139
+ json: false,
140
+ report: null,
141
+ install: true,
142
+ typecheck: true,
143
+ };
144
+ const value = (flag) => {
145
+ const v = args.shift();
146
+ if (v === undefined || v.startsWith('--')) throw new Error(`${flag} needs a value`);
147
+ return v;
148
+ };
149
+ try {
150
+ while (args.length > 0) {
151
+ const flag = args.shift();
152
+ if (flag === '--help' || flag === '-h') return { help: true };
153
+ else if (flag === '--root') options.root = resolve(value(flag));
154
+ else if (flag === '--from') options.from = value(flag);
155
+ else if (flag === '--to') options.to = value(flag);
156
+ else if (flag === '--dry-run') options.mode = options.mode === 'check' ? 'check' : 'dry-run';
157
+ else if (flag === '--check') options.mode = 'check';
158
+ else if (flag === '--json') options.json = true;
159
+ else if (flag === '--report') options.report = resolve(value(flag));
160
+ else if (flag === '--no-install') options.install = false;
161
+ else if (flag === '--no-typecheck') options.typecheck = false;
162
+ else return { error: `unknown argument: ${flag}` };
163
+ }
164
+ } catch (error) {
165
+ return { error: error.message };
166
+ }
167
+ for (const key of ['from', 'to']) {
168
+ if (options[key] !== null && !isVersion(options[key])) {
169
+ return {
170
+ error: `--${key} needs a version such as the one in package.json, got ${options[key]}`,
171
+ };
172
+ }
173
+ }
174
+ return { options };
175
+ }
176
+
177
+ // ── package.json edits ───────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Rewrite a package.json through `mutate`, keeping its indent, line endings
181
+ * and final newline. Written to a temp file and renamed over it, so a crash
182
+ * never leaves half a package.json.
183
+ */
184
+ function editPackageJson(file, mutate) {
185
+ const text = readFileSync(file, 'utf8');
186
+ const pkg = JSON.parse(text);
187
+ mutate(pkg);
188
+ const indent = /^[ \t]+(?=")/m.exec(text)?.[0] ?? ' ';
189
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
190
+ const body = JSON.stringify(pkg, null, indent).replace(/\n/g, eol);
191
+ const next = `${body}${/\r?\n$/.test(text) ? eol : ''}`;
192
+ if (next === text) return false;
193
+ writeFileAtomic(file, next);
194
+ return true;
195
+ }
196
+
197
+ /** Add `name: range` to an object, in place, keeping it sorted when it was. */
198
+ function addSorted(obj, name, range) {
199
+ const keys = Object.keys(obj);
200
+ const sorted = keys.every((k, i) => i === 0 || keys[i - 1] <= k);
201
+ obj[name] = range;
202
+ if (!sorted) return obj;
203
+ const entries = Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
204
+ for (const k of Object.keys(obj)) delete obj[k];
205
+ for (const [k, v] of entries) obj[k] = v;
206
+ return obj;
207
+ }
208
+
209
+ // ── report ───────────────────────────────────────────────────────────────────
210
+
211
+ function createReport(record, options) {
212
+ return {
213
+ package: HDS_PACKAGE,
214
+ tool: record.version,
215
+ mode: options.mode,
216
+ target: options.to ?? record.version,
217
+ floor: record.index.floor,
218
+ packageManager: null,
219
+ lockfile: null,
220
+ importers: [],
221
+ split: false,
222
+ fixedForYou: [],
223
+ looksDifferent: [],
224
+ comingNext: [],
225
+ doByHand: [],
226
+ changedFiles: [],
227
+ refused: null,
228
+ exitCode: 0,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Items merge by id across importers; `severity` orders Do by hand. A Do by
234
+ * hand item is blocking (it keeps the exit code at 1) while the code still
235
+ * needs an edit; a behavior change with nothing to edit is listed to check
236
+ * and does not block.
237
+ */
238
+ function addItem(
239
+ report,
240
+ list,
241
+ { id, plain, importer, files = [], removeIn, impact, blocking = true },
242
+ ) {
243
+ let item = report[list].find((i) => i.id === id);
244
+ if (!item) {
245
+ item = { id, plain, importers: [], files: [] };
246
+ if (removeIn) item.removeIn = removeIn;
247
+ if (list === 'doByHand') item.blocking = blocking;
248
+ Object.defineProperty(item, 'severity', {
249
+ value: SEVERITY.indexOf(impact ?? 'behavior'),
250
+ enumerable: false,
251
+ });
252
+ report[list].push(item);
253
+ }
254
+ if (importer && !item.importers.includes(importer)) item.importers.push(importer);
255
+ for (const f of files) if (!item.files.includes(f)) item.files.push(f);
256
+ }
257
+
258
+ const refuse = (report, message) => {
259
+ report.refused = message;
260
+ report.exitCode = 2;
261
+ return report;
262
+ };
263
+
264
+ const under = (dir, rel) => (dir === '.' ? rel : `${dir}/${rel}`);
265
+
266
+ /** Enough for any install log: spawnSync kills a child that prints past it. */
267
+ const TOOL_MAX_BUFFER = 512 * 1024 * 1024;
268
+
269
+ /** Run a command, capturing its output; null status when it could not start. Never a shell. */
270
+ function runTool(cmd, args, cwd) {
271
+ const res = spawnSync(cmd, args, {
272
+ cwd,
273
+ encoding: 'utf8',
274
+ timeout: 15 * 60_000,
275
+ maxBuffer: TOOL_MAX_BUFFER,
276
+ });
277
+ return {
278
+ status: res.error ? null : res.status,
279
+ missing: res.error?.code === 'ENOENT',
280
+ output: `${res.stdout ?? ''}${res.stderr ?? ''}${res.error && res.error.code !== 'ENOENT' ? res.error.message : ''}`,
281
+ };
282
+ }
283
+
284
+ /** The only words the package manager is ever run with: names and subcommands, no paths. */
285
+ const SAFE_WORD = /^[\w:-]+$/;
286
+
287
+ /**
288
+ * Run the package manager. On Windows it is a .cmd shim, which Node only
289
+ * starts through cmd.exe, so cmd.exe is spawned directly (never `shell: true`)
290
+ * with a command line of fixed words (the manager's name and `install` /
291
+ * `run typecheck`), each checked against SAFE_WORD so no path or user input
292
+ * can reach it; cwd is passed as an option.
293
+ */
294
+ function runManager(pm, args, cwd) {
295
+ const words = [pm, ...args];
296
+ if (!words.every((w) => SAFE_WORD.test(w))) {
297
+ throw new Error(`refusing to run ${words.join(' ')}: only plain words reach the shell`);
298
+ }
299
+ if (process.platform !== 'win32') return runTool(pm, args, cwd);
300
+ const res = spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', words.join(' ')], {
301
+ cwd,
302
+ encoding: 'utf8',
303
+ windowsVerbatimArguments: true,
304
+ timeout: 15 * 60_000,
305
+ maxBuffer: TOOL_MAX_BUFFER,
306
+ });
307
+ return {
308
+ status: res.error ? null : res.status,
309
+ missing: res.status === 9009 || !!res.error,
310
+ output: `${res.stdout ?? ''}${res.stderr ?? ''}`,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * A copy of `files` (from collectFiles) in a new tmp directory, for the
316
+ * codemods to rewrite in a preview: the detectors then read what the run
317
+ * would leave. The caller removes it.
318
+ */
319
+ function overlayOf(files) {
320
+ const dir = mkdtempSync(join(tmpdir(), 'hds-upgrade-preview-'));
321
+ for (const file of files) {
322
+ const abs = join(dir, ...file.rel.split('/'));
323
+ mkdirSync(dirname(abs), { recursive: true });
324
+ writeFileSync(abs, file.text);
325
+ }
326
+ return dir;
327
+ }
328
+
329
+ /** The line of a tool's output that best says what failed. */
330
+ function firstError(output) {
331
+ const lines = output
332
+ .split(/\r?\n/)
333
+ .map((l) => l.trim())
334
+ .filter(Boolean);
335
+ const line = lines.find((l) => /\bTS\d{4}\b|error/i.test(l)) ?? lines.at(-1) ?? 'no output';
336
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
337
+ }
338
+
339
+ /** The nearest node_modules/typescript/bin/tsc (a JS file node runs) from `dir` up to `root`, or null. */
340
+ function findTsc(dir, root) {
341
+ for (let d = dir; ; d = dirname(d)) {
342
+ const bin = join(d, 'node_modules', 'typescript', 'bin', 'tsc');
343
+ if (existsSync(bin)) return bin;
344
+ if (d === root || dirname(d) === d) return null;
345
+ }
346
+ }
347
+
348
+ const WHY = {
349
+ pnpm: `pnpm why ${HDS_PACKAGE}`,
350
+ npm: `npm ls ${HDS_PACKAGE}`,
351
+ yarn: `yarn why ${HDS_PACKAGE}`,
352
+ bun: `bun pm ls`,
353
+ };
354
+
355
+ /**
356
+ * Upgrade one project. Never throws for a consumer problem: a refusal is a
357
+ * report with exitCode 2 and `refused` set, and nothing written.
358
+ * @param {ReturnType<typeof parseArgs>['options']} options
359
+ * @param {{ pkgDir?: string }} [env]
360
+ */
361
+ export async function upgrade(options, { pkgDir = PKG_DIR } = {}) {
362
+ const record = loadRecord(pkgDir);
363
+ const report = createReport(record, options);
364
+ const { target } = report;
365
+ const apply = options.mode === 'apply';
366
+ const root = options.root;
367
+
368
+ // ── 1. what is installed, and refusals (nothing written before this ends)
369
+ const known = record.ledgers.map((l) => l.version);
370
+ if (!known.includes(target)) {
371
+ return refuse(
372
+ report,
373
+ `No upgrade steps for ${target}: this copy of ${HDS_PACKAGE} (${record.version}) knows ${known.join(', ')}. Run npx ${HDS_PACKAGE}@latest upgrade, or pick one of those with --to.`,
374
+ );
375
+ }
376
+ const rootPkgFile = join(root, 'package.json');
377
+ if (!existsSync(rootPkgFile))
378
+ return refuse(report, `No package.json in ${root}: pass --root <project dir>.`);
379
+ let rootPkg;
380
+ try {
381
+ rootPkg = JSON.parse(readFileSync(rootPkgFile, 'utf8'));
382
+ } catch (error) {
383
+ return refuse(report, `${rootPkgFile} is not valid JSON (${error.message}).`);
384
+ }
385
+ const unreadable = [];
386
+ const importers = findImporters(root, unreadable);
387
+ if (unreadable.length > 0) {
388
+ return refuse(
389
+ report,
390
+ unreadable
391
+ .map(
392
+ ({ file, reason }) =>
393
+ `${file} is not valid JSON (${reason}), so this cannot tell whether it uses ${HDS_PACKAGE}: fix it, then run this command again.`,
394
+ )
395
+ .join('\n'),
396
+ );
397
+ }
398
+ if (importers.length === 0) {
399
+ return refuse(
400
+ report,
401
+ `No package.json under ${root} declares ${HDS_PACKAGE}, so there is nothing to upgrade.`,
402
+ );
403
+ }
404
+ const workspace = enclosingWorkspace(root);
405
+ if (workspace) {
406
+ const kind = detectManager(
407
+ workspace.dir,
408
+ readJson(join(workspace.dir, 'package.json')),
409
+ ).manager;
410
+ return refuse(
411
+ report,
412
+ `${workspace.rel} is part of the ${kind ?? 'npm'} workspace at ${workspace.dir}; run the command from ${workspace.dir} (or pass --root ${workspace.dir}), so the workspace's lockfile and package manager are used.`,
413
+ );
414
+ }
415
+ const { manager, lockfile } = detectManager(root, rootPkg);
416
+ report.packageManager = manager;
417
+ report.lockfile = lockfile;
418
+ const lock = readRootLockfile(root, lockfile, importers);
419
+ const versions = installedVersions({
420
+ root,
421
+ lockfile,
422
+ lock,
423
+ importers,
424
+ target,
425
+ floor: record.index.floor,
426
+ from: options.from,
427
+ });
428
+ const problems = [];
429
+ importers.forEach((importer, i) => {
430
+ const { from } = versions[i];
431
+ const where = importer.dir === '.' ? '' : ` in ${importer.dir}`;
432
+ if (from === null) {
433
+ problems.push(
434
+ `Cannot tell which version of ${HDS_PACKAGE} is installed${where}: no lockfile entry and no node_modules copy. Pass --from <version>.`,
435
+ );
436
+ } else if (compareVersions(from, record.index.floor) < 0) {
437
+ problems.push(
438
+ `Installed ${from} is older than the oldest release this tool can upgrade from (${record.index.floor}). Follow MIGRATIONS.md by hand up to ${record.index.floor}, or ask in hirobius/hds.${importer.dir === '.' ? '' : `\nIn ${importer.dir}.`}`,
439
+ );
440
+ } else if (compareVersions(from, target) > 0) {
441
+ problems.push(
442
+ `Installed ${from}${where} is newer than ${target}: this tool does not downgrade. Pass --to ${from} or newer.`,
443
+ );
444
+ }
445
+ });
446
+ report.importers = importers.map((importer, i) => ({
447
+ dir: importer.dir,
448
+ from: versions[i].from,
449
+ source: versions[i].source,
450
+ range: importer.range,
451
+ newRange: null,
452
+ }));
453
+ if (problems.length > 0) return refuse(report, [...new Set(problems)].join('\n'));
454
+ report.split = new Set(versions.map((v) => v.from)).size > 1;
455
+
456
+ // ── 2. each importer: codemods, range, dependencies, lists
457
+ const changed = new Set();
458
+ for (const [i, importer] of importers.entries()) {
459
+ const { from } = versions[i];
460
+ const dir = importer.dir;
461
+ const pkgFile = join(importer.abs, 'package.json');
462
+ const pkgRel = under(dir, 'package.json');
463
+ const scan = () => collectFiles(importer.abs, { skip: importer.nested });
464
+ const oversized = [];
465
+ const before = collectFiles(importer.abs, { skip: importer.nested, oversized });
466
+ const rel = (files) => files.map((f) => under(dir, f));
467
+ // Too big to read, so no detector saw it: never done while one names HDS.
468
+ const unread = oversized.filter((f) => fileMentions(f.abs, HDS_PACKAGE));
469
+ if (unread.length > 0) {
470
+ addItem(report, 'doByHand', {
471
+ id: 'tool/not-read',
472
+ plain: `These files name ${HDS_PACKAGE} but were not read (over ${Math.round(MAX_BYTES / 1e6)} MB), so check them by hand against the steps above, or split them.`,
473
+ importer: dir,
474
+ files: rel(unread.map((f) => f.rel)),
475
+ impact: 'breaking',
476
+ });
477
+ }
478
+ // A step from a release at or before `from` that takes a name away, or asks
479
+ // for a hand edit, still applies while the code matches it: a hand bump
480
+ // (or an earlier partial upgrade) can skip it, and the code then breaks.
481
+ const earlier = record.ledgers
482
+ .filter((l) => compareVersions(l.version, from) <= 0)
483
+ .flatMap((l) => l.steps)
484
+ .filter(
485
+ (s) =>
486
+ (REMOVING.has(s.kind) || s.kind === 'manual') &&
487
+ s.detect &&
488
+ matchDetect(s.detect, before).length > 0 &&
489
+ !(s.done && matchDetect(s.done, before).length > 0),
490
+ );
491
+ const steps = [
492
+ ...earlier,
493
+ ...record.ledgers
494
+ .filter(
495
+ (l) => compareVersions(l.version, from) > 0 && compareVersions(l.version, target) <= 0,
496
+ )
497
+ .flatMap((l) => l.steps),
498
+ ];
499
+
500
+ if (versions[i].noHistory) {
501
+ const where = versions[i].source === 'node_modules' ? 'node_modules' : 'the lockfile';
502
+ const why =
503
+ versions[i].noHistory === 'at-target'
504
+ ? `every commit of it in git says ${target} too`
505
+ : 'no git history says which version it was upgraded from';
506
+ addItem(report, 'doByHand', {
507
+ id: 'tool/no-history',
508
+ plain: `${HDS_PACKAGE} is already ${target} in ${where}, and ${why}, so this checked it as already upgraded. If it was upgraded by hand, pass --from <old version> to list what is left.`,
509
+ importer: dir,
510
+ files: [pkgRel],
511
+ blocking: false,
512
+ });
513
+ }
514
+ if (versions[i].belowFloor) {
515
+ addItem(report, 'doByHand', {
516
+ id: 'tool/below-floor',
517
+ plain: `The lockfile's git history says ${HDS_PACKAGE} was ${versions[i].belowFloor}, older than ${record.index.floor}, the oldest release this tool knows, so it checked from ${record.index.floor} on. For the releases before it, follow MIGRATIONS.md.`,
518
+ importer: dir,
519
+ files: [pkgRel],
520
+ blocking: false,
521
+ });
522
+ }
523
+
524
+ // Codemods, each once, in the order their steps come.
525
+ const codemods = [
526
+ ...new Set(
527
+ steps.filter((s) => s.auto && hasCodemod(s.auto.codemod)).map((s) => s.auto.codemod),
528
+ ),
529
+ ];
530
+ // A preview runs the codemods on a copy, so the lists below match the run.
531
+ const overlay = !apply && codemods.length > 0 ? overlayOf(before) : null;
532
+ for (const name of codemods) {
533
+ let res;
534
+ try {
535
+ res = await runCodemod(
536
+ name,
537
+ overlay
538
+ ? { root: overlay, write: true, skip: [] }
539
+ : { root: importer.abs, write: apply, skip: importer.nested },
540
+ );
541
+ } catch (error) {
542
+ addItem(report, 'doByHand', {
543
+ id: `${name}/failed`,
544
+ plain: `${name} stopped partway (${error.message.split('\n')[0]}), so fix that, then run this command again; files it already rewrote stay rewritten.`,
545
+ importer: dir,
546
+ files: [],
547
+ impact: 'breaking',
548
+ });
549
+ continue;
550
+ }
551
+ if (apply) for (const f of res.files) changed.add(under(dir, f));
552
+ for (const m of res.manual) {
553
+ if (m.removed) continue; // the ledger's removed step says it, with the fix
554
+ addItem(report, 'doByHand', {
555
+ id: `${name}/manual`,
556
+ plain: `${name} could not rewrite every use, so edit these by hand: ${m.stmt}.`.replace(
557
+ /\.\.$/,
558
+ '.',
559
+ ),
560
+ importer: dir,
561
+ files: [under(dir, m.file)],
562
+ });
563
+ }
564
+ }
565
+ let now = before;
566
+ if (overlay) {
567
+ try {
568
+ now = collectFiles(overlay);
569
+ } finally {
570
+ rmSync(overlay, { recursive: true, force: true });
571
+ }
572
+ } else if (apply && codemods.length > 0) now = scan();
573
+
574
+ for (const step of steps) {
575
+ if (step.kind === 'deprecated') continue; // Coming next, below
576
+ const done = step.done && matchDetect(step.done, now).length > 0;
577
+ if (step.auto && hasCodemod(step.auto.codemod)) {
578
+ const was = matchDetect(step.detect, before);
579
+ const still = matchDetect(step.detect, now);
580
+ if (still.length > 0) {
581
+ addItem(report, 'doByHand', {
582
+ id: step.id,
583
+ plain: step.plain,
584
+ importer: dir,
585
+ files: rel(still),
586
+ impact: step.impact,
587
+ });
588
+ } else if (was.length > 0) {
589
+ addItem(report, 'fixedForYou', {
590
+ id: step.id,
591
+ plain: step.plain,
592
+ importer: dir,
593
+ files: rel(was),
594
+ });
595
+ }
596
+ continue;
597
+ }
598
+ const hits = step.detect ? matchDetect(step.detect, now) : null;
599
+ if (step.kind === 'dependency' && step.range && step.detect) {
600
+ const name = step.id.split('/').slice(2).join('/');
601
+ if (!hits.length || DEP_FIELDS.some((f) => importer.pkg?.[f]?.[name] !== undefined))
602
+ continue;
603
+ if (apply) {
604
+ editPackageJson(pkgFile, (pkg) => {
605
+ pkg.dependencies = addSorted(pkg.dependencies ?? {}, name, step.range);
606
+ });
607
+ importer.pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
608
+ changed.add(pkgRel);
609
+ }
610
+ addItem(report, 'fixedForYou', {
611
+ id: step.id,
612
+ plain: `Added ${name} ${step.range} to dependencies: ${HDS_PACKAGE} no longer installs it and your code imports it.`,
613
+ importer: dir,
614
+ files: rel(hits),
615
+ });
616
+ continue;
617
+ }
618
+ if (done || (hits !== null && hits.length === 0)) continue;
619
+ const list = listOf(step) === 'looksDifferent' ? 'looksDifferent' : 'doByHand';
620
+ if (list === 'looksDifferent' && hits === null) continue;
621
+ addItem(report, list, {
622
+ id: step.id,
623
+ plain: step.plain,
624
+ importer: dir,
625
+ files: rel(hits ?? []),
626
+ impact: step.impact,
627
+ blocking: blocks(step),
628
+ });
629
+ }
630
+
631
+ for (const step of comingNextSteps(record.ledgers, target)) {
632
+ const hits = step.detect ? matchDetect(step.detect, now) : [];
633
+ if (hits.length === 0) continue;
634
+ if (step.done && matchDetect(step.done, now).length > 0) continue;
635
+ addItem(report, 'comingNext', {
636
+ id: step.id,
637
+ plain: step.plain,
638
+ importer: dir,
639
+ files: rel(hits),
640
+ removeIn: step.removeIn,
641
+ });
642
+ }
643
+
644
+ // The range, with its operator kept, in every field that declares it.
645
+ const entry = report.importers[i];
646
+ const fields = DEP_FIELDS.filter((f) => typeof importer.pkg?.[f]?.[HDS_PACKAGE] === 'string');
647
+ const bumps = fields.map((f) => [
648
+ f,
649
+ importer.pkg[f][HDS_PACKAGE],
650
+ bumpRange(importer.pkg[f][HDS_PACKAGE], target),
651
+ ]);
652
+ const stuck = bumps.filter(([, , next]) => next === null);
653
+ for (const [field, range] of stuck) {
654
+ addItem(report, 'doByHand', {
655
+ id: 'tool/range',
656
+ plain: `Set ${HDS_PACKAGE} to ^${target} by hand: this tool does not rewrite a range like ${range} (${field}).`,
657
+ importer: dir,
658
+ files: [pkgRel],
659
+ });
660
+ }
661
+ const moves = bumps.filter(([, range, next]) => next !== null && next !== range);
662
+ entry.newRange = bumpRange(importer.range, target) ?? null;
663
+ if (moves.length > 0) {
664
+ if (apply) {
665
+ editPackageJson(pkgFile, (pkg) => {
666
+ for (const [field, , next] of moves) pkg[field][HDS_PACKAGE] = next;
667
+ });
668
+ changed.add(pkgRel);
669
+ }
670
+ addItem(report, 'fixedForYou', {
671
+ id: 'range',
672
+ plain: `Set ${HDS_PACKAGE} to ${target} in package.json, keeping each range's operator.`,
673
+ importer: dir,
674
+ files: [pkgRel],
675
+ });
676
+ }
677
+ }
678
+
679
+ // ── 3. a second copy of HDS the importers do not explain
680
+ if (lock?.versions?.length > 1) {
681
+ const moving = new Set(importers.map((imp) => lock.importers?.[imp.dir]).filter(Boolean));
682
+ const extra = lock.versions.filter((v) => v !== target && !moving.has(v));
683
+ if (extra.length > 0) {
684
+ addItem(report, 'doByHand', {
685
+ id: 'tool/two-copies',
686
+ plain: `The lockfile holds two copies of HDS (${lock.versions.join(' and ')}): something other than your package.json pulls in ${extra.join(' and ')}, so find it with \`${WHY[manager] ?? WHY.npm}\` and update or dedupe it.`,
687
+ files: [lockfile],
688
+ impact: 'breaking',
689
+ });
690
+ }
691
+ }
692
+
693
+ // ── 4. install and typecheck
694
+ const pm = manager ?? 'npm';
695
+ let installed = false;
696
+ if (apply && options.install) {
697
+ const stale = importers.some((imp) => lock?.importers?.[imp.dir] !== target);
698
+ if (changed.size > 0 || stale) {
699
+ installed = true;
700
+ const res = runManager(pm, ['install'], root);
701
+ if (res.status !== 0) {
702
+ addItem(report, 'doByHand', {
703
+ id: 'tool/install',
704
+ plain: `Run \`${pm} install\`: it failed here (${res.missing ? `${pm} not found` : firstError(res.output)}).`,
705
+ files: [],
706
+ });
707
+ }
708
+ }
709
+ }
710
+ // Still short of the target (no install, --no-install, a preview, or an
711
+ // install that did not move it): never done until it is installed.
712
+ // An install can write the first lockfile, so look for it again.
713
+ const lockAfter = installed ? (detectManager(root, rootPkg).lockfile ?? lockfile) : lockfile;
714
+ const after = installed ? readRootLockfile(root, lockAfter, importers) : lock;
715
+ for (const importer of importers) {
716
+ const locked = after?.importers?.[importer.dir];
717
+ const now = isVersion(locked) ? locked : nodeModulesVersion(root, importer);
718
+ if (!now) {
719
+ // No lockfile entry and no node_modules copy says what is installed:
720
+ // the version came from --from, so nothing shows the target is in yet.
721
+ addItem(report, 'doByHand', {
722
+ id: 'tool/install',
723
+ plain: `Run \`${pm} install\` to finish: nothing installed says ${target} yet (${lockAfter ? `${lockAfter} has no readable entry for it` : 'no lockfile'}, and no copy in node_modules).`,
724
+ importer: importer.dir,
725
+ files: lockAfter ? [lockAfter] : [],
726
+ });
727
+ continue;
728
+ }
729
+ if (compareVersions(now, target) === 0) continue;
730
+ const what = isVersion(locked) ? `${lockAfter} still resolves` : 'node_modules still holds';
731
+ addItem(report, 'doByHand', {
732
+ id: 'tool/install',
733
+ plain: `Run \`${pm} install\` to finish: ${what} ${now}, not ${target}.`,
734
+ importer: importer.dir,
735
+ files: isVersion(locked) ? [lockAfter] : [],
736
+ });
737
+ }
738
+ if (apply && options.typecheck) {
739
+ for (const importer of importers) {
740
+ let res = null;
741
+ if (importer.pkg?.scripts?.typecheck) {
742
+ res = runManager(pm, ['run', 'typecheck'], importer.abs);
743
+ } else if (existsSync(join(importer.abs, 'tsconfig.json'))) {
744
+ const tsc = findTsc(importer.abs, root);
745
+ if (tsc) res = runTool(process.execPath, [tsc, '--noEmit'], importer.abs);
746
+ }
747
+ if (!res) continue;
748
+ if (res.status !== 0) {
749
+ addItem(report, 'doByHand', {
750
+ id: 'tool/typecheck',
751
+ plain: `Typecheck fails${importer.dir === '.' ? '' : ` in ${importer.dir}`} (${firstError(res.output)}), so fix what it reports, then run this command again.`,
752
+ importer: importer.dir,
753
+ files: [],
754
+ impact: 'breaking',
755
+ });
756
+ }
757
+ }
758
+ }
759
+
760
+ // ── 5. order and exit
761
+ report.doByHand = report.doByHand
762
+ .map((item, i) => [item, i])
763
+ .sort(([a, i], [b, j]) => b.blocking - a.blocking || b.severity - a.severity || i - j)
764
+ .map(([item]) => item);
765
+ report.changedFiles = [...changed].sort();
766
+ const pending = options.mode === 'apply' ? 0 : report.fixedForYou.length;
767
+ const blocking = report.doByHand.filter((item) => item.blocking).length;
768
+ report.exitCode = pending + blocking > 0 ? 1 : 0;
769
+ return report;
770
+ }
771
+
772
+ // ── text output ─────────────────────────────────────────────────────────────
773
+
774
+ const MAX_FILES = 5;
775
+
776
+ export function formatReport(report) {
777
+ if (report.refused) return report.refused;
778
+ const out = [];
779
+ const preview = report.mode !== 'apply';
780
+ const how = [report.packageManager, report.lockfile].filter(Boolean).join(', ');
781
+ out.push(`${HDS_PACKAGE} upgrade to ${report.target}${how ? ` (${how})` : ''}`);
782
+ for (const imp of report.importers) {
783
+ const where =
784
+ {
785
+ lockfile: report.lockfile,
786
+ node_modules: 'node_modules',
787
+ flag: '--from',
788
+ 'git-head': 'lockfile at git HEAD',
789
+ 'git-merge-base': 'lockfile at the merge-base',
790
+ 'git-log': 'lockfile in git history',
791
+ }[imp.source] ?? imp.source;
792
+ out.push(` ${imp.dir} ${imp.from} -> ${report.target} (installed version from ${where})`);
793
+ }
794
+ if (report.split) {
795
+ out.push(
796
+ ' Workspace split: these importers start from different versions; each is upgraded from its own.',
797
+ );
798
+ }
799
+ if (preview)
800
+ out.push(
801
+ report.mode === 'check'
802
+ ? ' --check: nothing was written.'
803
+ : ' --dry-run: nothing was written.',
804
+ );
805
+ for (const list of LISTS) {
806
+ out.push(
807
+ '',
808
+ `${HEADINGS[list]}${preview && list === 'fixedForYou' ? ' (when run without --dry-run or --check)' : ''}`,
809
+ );
810
+ if (report[list].length === 0) out.push(' (none)');
811
+ for (const item of report[list]) {
812
+ const removal = item.removeIn ? ` Removed in ${item.removeIn}.` : '';
813
+ const check = item.blocking === false ? 'Check: ' : '';
814
+ out.push(` - ${check}${item.plain}${removal}`);
815
+ const shown = item.files.slice(0, MAX_FILES);
816
+ if (shown.length > 0)
817
+ out.push(
818
+ ` ${shown.join(', ')}${item.files.length > MAX_FILES ? ` and ${item.files.length - MAX_FILES} more` : ''}`,
819
+ );
820
+ }
821
+ }
822
+ out.push('');
823
+ const left = report.doByHand.filter((item) => item.blocking).length;
824
+ const checks = report.doByHand.length - left;
825
+ const toCheck =
826
+ checks > 0 ? `; ${checks} behavior ${checks === 1 ? 'change' : 'changes'} above to check` : '';
827
+ if (report.exitCode === 0) {
828
+ out.push(
829
+ preview
830
+ ? `Nothing left to do${toCheck}.`
831
+ : `Done: on ${report.target}, nothing left to do by hand${toCheck}.`,
832
+ );
833
+ } else if (preview) {
834
+ const fix = report.fixedForYou.length;
835
+ const parts = [
836
+ fix > 0 ? `${fix} to fix (run without --check or --dry-run)` : null,
837
+ left > 0 ? `${left} to do by hand` : null,
838
+ ].filter(Boolean);
839
+ out.push(`Work left: ${parts.join(', ')}.`);
840
+ } else {
841
+ out.push(
842
+ `${left} ${left === 1 ? 'item' : 'items'} left to do by hand; run this command again to check them off.`,
843
+ );
844
+ }
845
+ return out.join('\n');
846
+ }
847
+
848
+ // ── CLI ─────────────────────────────────────────────────────────────────────
849
+
850
+ export async function main(argv, invokedAs = '') {
851
+ const parsed = parseArgs(argv, invokedAs);
852
+ if (parsed.help) {
853
+ console.log(USAGE);
854
+ return 0;
855
+ }
856
+ if (parsed.error) {
857
+ console.error(`${parsed.error}\n\n${USAGE}`);
858
+ return 2;
859
+ }
860
+ const { options } = parsed;
861
+ // Check --report's directory before anything is written, so a bad path
862
+ // never fails after the upgrade has already changed files.
863
+ if (options.report) {
864
+ const dir = dirname(options.report);
865
+ let ok = false;
866
+ try {
867
+ ok = statSync(dir).isDirectory();
868
+ } catch {
869
+ ok = false;
870
+ }
871
+ if (!ok) {
872
+ console.error(
873
+ `Cannot write --report ${options.report}: ${dir} is not a directory; pass a path in an existing directory.`,
874
+ );
875
+ return 2;
876
+ }
877
+ }
878
+ let report;
879
+ try {
880
+ report = await upgrade(options);
881
+ } catch (error) {
882
+ const changed =
883
+ options.mode === 'apply'
884
+ ? ' Some files may already be changed: check `git status` before running it again.'
885
+ : ' Nothing was written.';
886
+ console.error(`upgrade failed: ${error.stack ?? error.message}${changed}`);
887
+ return 2;
888
+ }
889
+ const json = `${JSON.stringify(report, null, 2)}\n`;
890
+ if (options.report) {
891
+ try {
892
+ writeFileAtomic(options.report, json);
893
+ } catch (error) {
894
+ console.error(
895
+ `Cannot write --report ${options.report}: ${error.message}; pass a path in an existing directory you can write to.`,
896
+ );
897
+ return 2;
898
+ }
899
+ }
900
+ if (options.json) process.stdout.write(json);
901
+ if (report.refused) console.error(report.refused);
902
+ else if (!options.json) console.log(formatReport(report));
903
+ return report.exitCode;
904
+ }
905
+
906
+ // isEntry compares real paths: npm/yarn link the bin and pnpm links the package directory.
907
+ if (isEntry(import.meta.url)) {
908
+ process.exitCode = await main(process.argv.slice(2), basename(process.argv[1] ?? ''));
909
+ }