@sempods/apps 0.0.0-stage → 0.7.0-dev.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +19 -0
  3. package/README.md +50 -2
  4. package/bin/sempods-apps.mjs +36 -0
  5. package/instructions/app-workflow.md +210 -0
  6. package/instructions/publish.md +148 -0
  7. package/instructions/setup.md +112 -0
  8. package/package.json +59 -3
  9. package/scripts/build-site.mjs +181 -0
  10. package/scripts/check.mjs +260 -0
  11. package/scripts/configure-site.mjs +118 -0
  12. package/scripts/create.mjs +83 -0
  13. package/scripts/dev.mjs +19 -0
  14. package/scripts/lib/apps.mjs +164 -0
  15. package/scripts/lib/files.mjs +57 -0
  16. package/scripts/lib/generate.mjs +175 -0
  17. package/scripts/lib/html.mjs +3 -0
  18. package/scripts/lib/json.mjs +42 -0
  19. package/scripts/lib/merge.mjs +159 -0
  20. package/scripts/lib/overview.mjs +105 -0
  21. package/scripts/lib/paths.mjs +33 -0
  22. package/scripts/lib/pnpm.mjs +30 -0
  23. package/scripts/lib/sdk.mjs +45 -0
  24. package/scripts/lib/shared.mjs +221 -0
  25. package/scripts/migrate.mjs +550 -0
  26. package/scripts/new-app.mjs +166 -0
  27. package/scripts/regenerate.mjs +41 -0
  28. package/scripts/sdk-update.mjs +220 -0
  29. package/scripts/update.mjs +152 -0
  30. package/shared/files/0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0 +11 -0
  31. package/shared/files/1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 +61 -0
  32. package/shared/files/31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 +25 -0
  33. package/shared/files/393b09ecd18889d9e1fdf2a6df12d1f6c193fc7fcd89ba808ad94df521bad8db +41 -0
  34. package/shared/files/4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654 +22 -0
  35. package/shared/files/528d7544990228d6d66fa62423775338fe1716326fc68371fb2665aee86e39b0 +6 -0
  36. package/shared/files/676e6bf33b44044fb4c62c9fe5441729bd47f2d5077ff904e57a4a1b658859ac +38 -0
  37. package/shared/files/8b7567319e18694aeca8722107281a760e3100239c6d0f548acb71e1806fa172 +28 -0
  38. package/shared/files/9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327 +4 -0
  39. package/shared/files/b911698dc3cf3227d34d0ad6727f1c4408791203c9665de99ab959331a1fb07d +1 -0
  40. package/shared/files/c2a4b026a87f6c8e7473e7fc456c6020b63c7e675087f6b58013ed5163c2f6ee +23 -0
  41. package/shared/files/d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee +11 -0
  42. package/shared/files/e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e +78 -0
  43. package/shared/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +0 -0
  44. package/shared/files/f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 +57 -0
  45. package/shared/files/f9e2544290ee8418484e47194444ee58c2ea675682871ae8a8727d1edb17978e +5 -0
  46. package/shared/files/ff9f774d9732fdc7d704ef37ddc40ef507a1bab7ee3c1db5b1a6a8f26e6e4b37 +35 -0
  47. package/shared/snapshot.json +25 -0
  48. package/skeleton/app/.oxlintrc.json +14 -0
  49. package/skeleton/app/NOTES.md +19 -0
  50. package/skeleton/app/index.html +16 -0
  51. package/skeleton/app/package.json +28 -0
  52. package/skeleton/app/public/favicon.svg +1 -0
  53. package/skeleton/app/public/icon-192.png +0 -0
  54. package/skeleton/app/public/icon-512.png +0 -0
  55. package/skeleton/app/src/App.tsx +73 -0
  56. package/skeleton/app/src/NewVersionNotice.tsx +63 -0
  57. package/skeleton/app/src/index.css +37 -0
  58. package/skeleton/app/src/main.tsx +18 -0
  59. package/skeleton/app/src/pwa.ts +18 -0
  60. package/skeleton/app/src/start.test.ts +15 -0
  61. package/skeleton/app/src/start.ts +9 -0
  62. package/skeleton/app/tsconfig.app.json +27 -0
  63. package/skeleton/app/tsconfig.json +8 -0
  64. package/skeleton/app/tsconfig.node.json +23 -0
  65. package/skeleton/app/tsconfig.test.json +10 -0
  66. package/skeleton/app/vite.config.ts +13 -0
  67. package/skills/app-workflow/SKILL.md +22 -0
  68. package/skills/update/SKILL.md +69 -0
  69. package/update-policy.json +35 -0
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env node
2
+ // Applies the starter of the installed @sempods/apps to this repository, from
3
+ // the starter it last applied (.sempods-baseline.json). The script applies
4
+ // only what is unambiguous: a file only the starter changed, a new or dropped
5
+ // file nobody changed here, seed files, the starter entries of the root
6
+ // manifest. Everything that needs judgment is a manual case: the file stays
7
+ // as it is, and MIGRATION_DIR holds the list, the starter's versions and a
8
+ // merge suggestion for the assistant (update skill). With manual cases open,
9
+ // `pnpm run migrate --done` confirms that each was decided and completes:
10
+ // regenerate the apps' configuration, install, advance the baseline.
11
+ // Usage: pnpm run migrate [--done]
12
+ import { randomBytes } from 'node:crypto';
13
+ import {
14
+ cpSync,
15
+ existsSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ renameSync,
19
+ rmSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join, relative, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { parseArgs } from 'node:util';
25
+ import { readApps } from './lib/apps.mjs';
26
+ import {
27
+ isDirectory,
28
+ isFile,
29
+ isSymlink,
30
+ readText,
31
+ removeFile,
32
+ replaceFile,
33
+ } from './lib/files.mjs';
34
+ import { GENERATED_FILES } from './lib/generate.mjs';
35
+ import { formatJson, ifPresent, readJson, replaceJson } from './lib/json.mjs';
36
+ import { mergeManifest, mergeText, withSection } from './lib/merge.mjs';
37
+ import { repositoryRoot, TOOLING } from './lib/paths.mjs';
38
+ import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
39
+ import { compareVersions, EXACT_VERSION, PENDING_UPDATE } from './lib/sdk.mjs';
40
+ import {
41
+ baselineSnapshot,
42
+ BASELINE_FILE,
43
+ declaredVersion,
44
+ downloadPackage,
45
+ installedVersion,
46
+ PACKAGE,
47
+ readBaseline,
48
+ starterAt,
49
+ writeBaseline,
50
+ } from './lib/shared.mjs';
51
+ import { regenerate } from './regenerate.mjs';
52
+
53
+ export const MIGRATION_DIR = '.sempods-migration';
54
+ export const MIGRATION_FILE = `${MIGRATION_DIR}/migration.json`;
55
+ const label = (migration) => `sempods-migration:${migration.id}`;
56
+
57
+ /**
58
+ * The migration recorded in MIGRATION_DIR while it is open: from the current
59
+ * baseline. A record whose target the baseline already is was completed.
60
+ */
61
+ export function openMigration(root, baseline) {
62
+ const migration = ifPresent(() => readJson(join(root, MIGRATION_FILE)));
63
+ return migration && migration.from.revision === baseline.revision
64
+ ? migration
65
+ : undefined;
66
+ }
67
+
68
+ /** Stops before anything changes while an SDK update is unfinished. */
69
+ export function refuseUnfinishedSdkUpdate(root) {
70
+ if (existsSync(join(root, PENDING_UPDATE)))
71
+ throw new Error(
72
+ `an SDK update is unfinished (${PENDING_UPDATE}); complete it with pnpm run sdk-update first. Nothing was changed.`,
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Symbolic links where an update writes: the command's own files, the apps'
78
+ * generated configuration and the given starter paths, each with its
79
+ * ancestors, and anything inside the migration folder.
80
+ */
81
+ export function linkedPaths(root, files = []) {
82
+ const found = new Set();
83
+ // A broken apps.json names no apps here; regenerating reports it later.
84
+ const apps = (() => {
85
+ try {
86
+ return readApps(root).apps;
87
+ } catch {
88
+ return [];
89
+ }
90
+ })();
91
+ for (const path of [
92
+ 'package.json',
93
+ 'pnpm-lock.yaml',
94
+ 'node_modules',
95
+ BASELINE_FILE,
96
+ MIGRATION_DIR,
97
+ ...apps.flatMap(({ id }) =>
98
+ GENERATED_FILES.map((file) => `apps/${id}/${file}`),
99
+ ),
100
+ ...files,
101
+ ]) {
102
+ const parts = path.split('/');
103
+ for (let i = 1; i <= parts.length; i += 1) {
104
+ const prefix = parts.slice(0, i).join('/');
105
+ if (isSymlink(join(root, prefix))) {
106
+ found.add(prefix);
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ if (isDirectory(join(root, MIGRATION_DIR)) && !found.has(MIGRATION_DIR))
112
+ for (const entry of readdirSync(join(root, MIGRATION_DIR), {
113
+ recursive: true,
114
+ withFileTypes: true,
115
+ }))
116
+ if (entry.isSymbolicLink())
117
+ found.add(relative(root, join(entry.parentPath, entry.name)));
118
+ return [...found].sort();
119
+ }
120
+
121
+ /** Stops before anything changes where an update would write through a link. */
122
+ export function refuseLinks(root, files) {
123
+ const links = linkedPaths(root, files);
124
+ if (links.length > 0)
125
+ throw new Error(
126
+ `the update writes where symbolic links are: ${links.join(', ')}. Replace each with the file or directory it points to, then run the command again. Nothing was changed.`,
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Stops a tooling update or migration that would be unsafe, before anything
132
+ * changes: during an unfinished SDK update, with a linked file the update
133
+ * writes, toward a version older than the
134
+ * repository's, or toward another version than an open migration's.
135
+ */
136
+ export function refuseUnsafe(root, version) {
137
+ refuseUnfinishedSdkUpdate(root);
138
+ refuseLinks(root);
139
+ const baseline = readBaseline(root);
140
+ for (const current of [
141
+ baseline?.version,
142
+ declaredVersion(root),
143
+ installedVersion(root),
144
+ ])
145
+ if (
146
+ EXACT_VERSION.test(current ?? '') &&
147
+ compareVersions(version, current) < 0
148
+ )
149
+ throw new Error(
150
+ `${PACKAGE} ${version} is older than this repository's ${current}; updates do not go back. Nothing was changed.`,
151
+ );
152
+ const open = baseline && openMigration(root, baseline);
153
+ if (open && open.to.version !== version)
154
+ throw new Error(
155
+ `an open migration to ${PACKAGE} ${open.to.version} is recorded in ${MIGRATION_FILE}. Finish it with that version installed (pnpm run migrate, or --done once its manual cases are decided), then update. Nothing was changed.`,
156
+ );
157
+ }
158
+
159
+ /** What `check` reports about the repository's starter state. */
160
+ export function migrationState(root, installed = TOOLING) {
161
+ const baseline = readBaseline(root);
162
+ if (!baseline || !existsSync(join(installed, 'shared', 'snapshot.json')))
163
+ return undefined;
164
+ const migration = openMigration(root, baseline);
165
+ if (migration)
166
+ return migration.manual.length > 0
167
+ ? `the migration to ${PACKAGE} ${migration.to.version} is open: decide the manual cases in ${MIGRATION_FILE} (${migration.manual.map(({ file }) => file).join(', ')}), then run pnpm run migrate --done`
168
+ : `the migration to ${PACKAGE} ${migration.to.version} is unfinished: run pnpm run migrate`;
169
+ const { revision } = readJson(join(installed, 'shared', 'snapshot.json'));
170
+ if (revision !== baseline.revision)
171
+ return `the shared files are from ${PACKAGE} ${baseline.version}, but the installed ${readJson(join(installed, 'package.json')).version} ships another starter: run pnpm run migrate`;
172
+ return undefined;
173
+ }
174
+
175
+ /**
176
+ * What the starter change means for each file: automatic writes (content, or
177
+ * null to remove) and manual cases with their reason and texts. Reads only;
178
+ * files of an open migration's manual cases (`skip`) are left to it.
179
+ */
180
+ function plan(root, { base, snapshot, policy, seeds, skip }) {
181
+ const auto = [];
182
+ const manual = [];
183
+ const sections = (text, ours, file) => {
184
+ let result = text;
185
+ for (const markers of policy.sections?.[file] ?? []) {
186
+ result = withSection(result, markers, ours);
187
+ if (result === null) return null;
188
+ }
189
+ return result;
190
+ };
191
+ const texts = (file) => ({
192
+ baseText: base.files[file] ? readText(base.path(file)) : null,
193
+ theirs: snapshot.files[file] ? readText(snapshot.path(file)) : null,
194
+ ours: readText(join(root, file)),
195
+ });
196
+ const files = [
197
+ ...new Set([...Object.keys(base.files), ...Object.keys(snapshot.files)]),
198
+ ].sort();
199
+ // Starter paths that change between file and directory here. Nothing at or
200
+ // below them is written automatically, on this run or a later one.
201
+ const blocked = new Map();
202
+ for (const file of files) {
203
+ const parts = file.split('/');
204
+ const obstacle = parts
205
+ .slice(0, -1)
206
+ .map((_, i) => parts.slice(0, i + 1).join('/'))
207
+ .find((dir) => isFile(join(root, dir)));
208
+ if (snapshot.files[file] && obstacle)
209
+ blocked.set(
210
+ file,
211
+ `the starter needs a directory where ${obstacle} is a file here`,
212
+ );
213
+ else if (isDirectory(join(root, file)))
214
+ blocked.set(
215
+ file,
216
+ snapshot.files[file]
217
+ ? 'a directory here, a file in the starter'
218
+ : 'a directory here; the starter dropped the file',
219
+ );
220
+ }
221
+ const below = (file) =>
222
+ [...blocked.keys()].some((path) => file.startsWith(`${path}/`));
223
+ // Paths that differ only in letter case are one file on case-insensitive
224
+ // file systems; such a rename needs a decision.
225
+ const caseRenamed = (file) =>
226
+ files.some(
227
+ (other) => other !== file && other.toLowerCase() === file.toLowerCase(),
228
+ );
229
+
230
+ for (const file of files) {
231
+ if (file === policy.rootManifest || skip.has(file) || below(file)) continue;
232
+ const { baseText, theirs, ours } = texts(file);
233
+ const add = (reason) =>
234
+ manual.push({ file, reason, baseText, theirs, ours });
235
+ if (blocked.has(file)) {
236
+ add(blocked.get(file));
237
+ continue;
238
+ }
239
+ if (caseRenamed(file)) {
240
+ add('the starter renames it by letter case only');
241
+ continue;
242
+ }
243
+ if (seeds.has(file)) {
244
+ // Written once, when a starter first brings it; the owner's from then on.
245
+ if (theirs !== null && ours === null && baseText === null)
246
+ auto.push({ file, content: theirs, kind: 'added' });
247
+ continue;
248
+ }
249
+ if (theirs === null) {
250
+ if (ours === baseText)
251
+ auto.push({ file, content: null, kind: 'removed' });
252
+ else if (ours !== null) add('the starter dropped it; changed here');
253
+ continue;
254
+ }
255
+ if (ours === null) {
256
+ if (baseText === null)
257
+ auto.push({ file, content: theirs, kind: 'added' });
258
+ else if (baseText !== theirs) add('removed here; the starter changed it');
259
+ continue;
260
+ }
261
+ // Compared with this repository's owner sections kept, so a changed owner
262
+ // section alone is no change to merge.
263
+ const expected = sections(theirs, ours, file);
264
+ if (expected === null) {
265
+ add('an owner section is incomplete here or in the starter');
266
+ continue;
267
+ }
268
+ // Already applied, or changed only here.
269
+ if (ours === expected || theirs === baseText) continue;
270
+ if (baseText !== null && ours === sections(baseText, ours, file))
271
+ auto.push({ file, content: expected, kind: 'updated' });
272
+ else
273
+ add(
274
+ baseText === null
275
+ ? 'added here and by the starter'
276
+ : 'changed here and by the starter',
277
+ );
278
+ }
279
+ return { auto, manual };
280
+ }
281
+
282
+ /**
283
+ * Adds manual cases to the migration record, with the starter's versions and
284
+ * a merge suggestion in MIGRATION_DIR, and saves it before anything changes.
285
+ */
286
+ function record(root, migration, cases, labels) {
287
+ const work = join(root, MIGRATION_DIR);
288
+ if (cases.length === 0 && existsSync(join(root, MIGRATION_FILE))) return;
289
+ for (const { file, reason, baseText, theirs, ours } of cases) {
290
+ if (baseText !== null) replaceFile(join(work, 'base', file), baseText);
291
+ if (theirs !== null) replaceFile(join(work, 'starter', file), theirs);
292
+ if (ours !== null && theirs !== null)
293
+ replaceFile(
294
+ join(work, 'suggestion', file),
295
+ mergeText(ours, baseText ?? '', theirs, labels).text,
296
+ );
297
+ migration.manual.push({ file, reason });
298
+ }
299
+ replaceJson(join(root, MIGRATION_FILE), migration);
300
+ }
301
+
302
+ /** The root manifest with the starter's tooling, SDK and script entries. */
303
+ function mergedManifest(root, { base, snapshot, policy, from, to }, notes) {
304
+ const file = policy.rootManifest;
305
+ const ours = readJson(join(root, file));
306
+ const starter = (snap, version) => {
307
+ const manifest = readJson(snap.path(file));
308
+ manifest.devDependencies[PACKAGE] = version;
309
+ return manifest;
310
+ };
311
+ // The SDK stays at the repository's version; sdk-update moves it.
312
+ const merged = formatJson(
313
+ mergeManifest(
314
+ ours,
315
+ starter(base, from),
316
+ starter(snapshot, to),
317
+ policy.sdk,
318
+ ours.devDependencies?.[policy.sdk[0]],
319
+ notes,
320
+ file,
321
+ ),
322
+ );
323
+ return merged === formatJson(ours) ? null : merged;
324
+ }
325
+
326
+ /**
327
+ * Migrates `root` from its baseline to the starter of the installed package.
328
+ * With `done`, the open manual cases count as decided. The other options are
329
+ * for tests: the installed package, how to fetch the baseline's package and
330
+ * how to install.
331
+ */
332
+ export function migrate(
333
+ root,
334
+ {
335
+ done = false,
336
+ installed = TOOLING,
337
+ download = downloadPackage,
338
+ install = () =>
339
+ pnpm(INSTALL, { cwd: root, stdio: ['ignore', 2, 2] }).status === 0,
340
+ } = {},
341
+ ) {
342
+ const baseline = readBaseline(root);
343
+ if (!baseline)
344
+ throw new Error(
345
+ `${BASELINE_FILE} is missing: this repository was not created from the ${PACKAGE} starter`,
346
+ );
347
+ const target = starterAt(installed);
348
+ refuseUnsafe(root, target.version);
349
+ const work = join(root, MIGRATION_DIR);
350
+ let migration = openMigration(root, baseline);
351
+ // Same version, other starter (a repacked package): the record does not
352
+ // describe this starter.
353
+ if (migration && migration.to.revision !== target.snapshot.revision)
354
+ throw new Error(
355
+ `the open migration in ${MIGRATION_FILE} was planned for another ${PACKAGE} ${target.version}; reinstall the package it was planned with, or remove ${MIGRATION_DIR}/ to plan again. Nothing was changed.`,
356
+ );
357
+ const report = {
358
+ from: baseline.version,
359
+ to: target.version,
360
+ updated: [],
361
+ added: [],
362
+ removed: [],
363
+ manual: [],
364
+ notes: [],
365
+ regenerated: [],
366
+ };
367
+ if (!migration) {
368
+ if (done)
369
+ throw new Error(
370
+ 'no migration is open; run pnpm run migrate first and decide its manual cases',
371
+ );
372
+ if (baseline.revision === target.snapshot.revision) {
373
+ // A leftover of a completed migration.
374
+ rmSync(work, { recursive: true, force: true });
375
+ return { ...report, unchanged: true };
376
+ }
377
+ }
378
+
379
+ // The baseline's package is fetched outside the repository and cached in
380
+ // MIGRATION_DIR only once the links are checked; an open migration reuses
381
+ // it. The copy is renamed into place, so a partial copy is never taken for
382
+ // the cached one.
383
+ const cache = join(work, 'package');
384
+ const unpacked = join(cache, 'package');
385
+ const fetched = mkdtempSync(join(tmpdir(), 'sempods-migrate-'));
386
+ let base;
387
+ try {
388
+ const cached =
389
+ migration &&
390
+ ifPresent(() => readJson(join(unpacked, 'package.json')))?.version;
391
+ let dir;
392
+ base = baselineSnapshot(baseline, {
393
+ download: (version) =>
394
+ (dir = version === cached ? unpacked : download(version, fetched)),
395
+ });
396
+ refuseLinks(root, [
397
+ ...Object.keys(base.files),
398
+ ...Object.keys(target.snapshot.files),
399
+ ]);
400
+ // Without an open record, anything in MIGRATION_DIR is a leftover of a
401
+ // completed migration.
402
+ if (!migration) rmSync(work, { recursive: true, force: true });
403
+ if (dir === join(fetched, 'package')) {
404
+ const partial = join(work, `package.${process.pid}`);
405
+ rmSync(partial, { recursive: true, force: true });
406
+ cpSync(fetched, partial, { recursive: true });
407
+ rmSync(cache, { recursive: true, force: true });
408
+ renameSync(partial, cache);
409
+ base = baselineSnapshot(baseline, { download: () => unpacked });
410
+ }
411
+ } finally {
412
+ rmSync(fetched, { recursive: true, force: true });
413
+ }
414
+ const { policy, snapshot } = target;
415
+ // A file that was a seed in either starter stays the owner's, also when the
416
+ // new starter drops it together with its policy entry.
417
+ const seeds = new Set([...policy.seed, ...(base.policy?.seed ?? [])]);
418
+ migration ??= {
419
+ id: randomBytes(4).toString('hex'),
420
+ from: baseline,
421
+ to: { version: target.version, revision: snapshot.revision },
422
+ manual: [],
423
+ };
424
+ const { auto, manual } = plan(root, {
425
+ base,
426
+ snapshot,
427
+ policy,
428
+ seeds,
429
+ skip: new Set(migration.manual.map(({ file }) => file)),
430
+ });
431
+ // The complete list of manual cases is saved before any repository file
432
+ // changes; a later run adds what has become ambiguous since.
433
+ // Both conflict markers carry the label, so --done can find either.
434
+ record(root, migration, manual, [
435
+ label(migration),
436
+ `${PACKAGE} ${baseline.version}`,
437
+ `${label(migration)} ${PACKAGE} ${target.version}`,
438
+ ]);
439
+
440
+ for (const { file, content, kind } of auto) {
441
+ if (content === null) removeFile(root, join(root, file));
442
+ else replaceFile(join(root, file), content);
443
+ report[kind].push(file);
444
+ }
445
+ const manifest = mergedManifest(
446
+ root,
447
+ { base, snapshot, policy, from: baseline.version, to: target.version },
448
+ report.notes,
449
+ );
450
+ if (manifest !== null) {
451
+ replaceFile(join(root, policy.rootManifest), manifest);
452
+ report.updated.push(policy.rootManifest);
453
+ }
454
+
455
+ report.manual = migration.manual;
456
+ // --done confirms only cases decided before this run; new ones come first.
457
+ if (done && manual.length > 0)
458
+ report.deferred = manual.map(({ file }) => file);
459
+ if (migration.manual.length > 0 && (!done || report.deferred)) {
460
+ // pnpm refuses `pnpm run` while the manifest and node_modules disagree,
461
+ // so the next command must find automatic changes installed.
462
+ const changed =
463
+ report.updated.length + report.added.length + report.removed.length > 0;
464
+ if (changed && !install(root))
465
+ report.notes.push(
466
+ 'pnpm install failed; fix the cause and run pnpm install before pnpm run migrate --done',
467
+ );
468
+ return { ...report, unfinished: true };
469
+ }
470
+ // --done attests that every manual case was decided; a copied suggestion
471
+ // with its conflicts unresolved is the one mistake the script can see.
472
+ const unresolved = migration.manual.filter(({ file }) => {
473
+ const text = readText(join(root, file)) ?? '';
474
+ return ['<<<<<<<', '>>>>>>>'].some((marker) =>
475
+ text.includes(`${marker} ${label(migration)}`),
476
+ );
477
+ });
478
+ if (unresolved.length > 0)
479
+ throw new Error(
480
+ `${unresolved.map(({ file }) => file).join(', ')} still contain an unresolved suggestion; resolve them, then run pnpm run migrate --done again`,
481
+ );
482
+ report.regenerated = regenerate(root);
483
+ // A new starter can change the manifest or the workspace settings, so the
484
+ // lockfile and node_modules follow before the migration counts as done.
485
+ if (!install(root))
486
+ return {
487
+ ...report,
488
+ unfinished: true,
489
+ failed:
490
+ 'pnpm install failed; fix the cause, then run the same command again',
491
+ };
492
+ writeBaseline(root, {
493
+ version: target.version,
494
+ revision: snapshot.revision,
495
+ });
496
+ rmSync(work, { recursive: true, force: true });
497
+ return report;
498
+ }
499
+
500
+ export function formatMigration(report) {
501
+ if (report.unchanged)
502
+ return `The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`;
503
+ const list = (title, items) =>
504
+ items.length ? [`${title}:`, ...items.map((item) => ` ${item}`)] : [];
505
+ const lines = [
506
+ `Migration from ${PACKAGE} ${report.from} to ${report.to}${report.unfinished ? ' is open' : ' is complete'}.`,
507
+ ...list('Updated', report.updated),
508
+ ...list('Added', report.added),
509
+ ...list('Removed', report.removed),
510
+ ...list('Regenerated', report.regenerated),
511
+ ...list('Notes', report.notes),
512
+ ];
513
+ if (report.failed) lines.push(report.failed);
514
+ else if (report.unfinished)
515
+ lines.push(
516
+ ...(report.deferred
517
+ ? [
518
+ `--done was not applied: ${report.deferred.join(', ')} became manual cases on this run.`,
519
+ ]
520
+ : []),
521
+ ...list(
522
+ `Manual cases (left unchanged; base, starter and suggestion are in ${MIGRATION_DIR}/)`,
523
+ report.manual.map(({ file, reason }) => `${file}: ${reason}`),
524
+ ),
525
+ 'Decide each case with the update skill, then run pnpm run migrate --done. The baseline stays at the old starter until then.',
526
+ );
527
+ else
528
+ lines.push(
529
+ 'Run pnpm run check and review the changes before you commit them.',
530
+ );
531
+ return lines.join('\n');
532
+ }
533
+
534
+ if (
535
+ process.argv[1] &&
536
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
537
+ ) {
538
+ try {
539
+ const { values } = parseArgs({
540
+ args: scriptArgs(),
541
+ options: { done: { type: 'boolean', default: false } },
542
+ });
543
+ const report = migrate(repositoryRoot(), { done: values.done });
544
+ console.log(formatMigration(report));
545
+ if (report.unfinished) process.exitCode = 3;
546
+ } catch (error) {
547
+ console.error(`migrate: ${error.message}`);
548
+ process.exitCode = 1;
549
+ }
550
+ }