@asrunjs/asrun 0.6.1 → 0.8.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 (74) hide show
  1. package/.claude/settings.json +5 -5
  2. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -103
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -128
  4. package/.claude/skills/making-cases/references/gaps.md +36 -36
  5. package/.claude/skills/making-cases/references/splitting.md +28 -28
  6. package/AGENTS.md +6 -0
  7. package/README.md +33 -9
  8. package/bin/asrun.mjs +19 -2
  9. package/dist/lib/ffmpeg.js +21 -10
  10. package/dist/lib/migrations.d.ts +106 -0
  11. package/dist/lib/migrations.js +106 -0
  12. package/dist/lib/paths.d.ts +9 -0
  13. package/dist/lib/paths.js +12 -1
  14. package/dist/lib/registry.d.ts +31 -0
  15. package/dist/lib/registry.js +66 -0
  16. package/dist/lib/runtime-deps.d.ts +59 -0
  17. package/dist/lib/runtime-deps.js +161 -0
  18. package/dist/lib/tar.d.ts +5 -0
  19. package/dist/lib/tar.js +79 -0
  20. package/dist/lib/update-check.d.ts +52 -0
  21. package/dist/lib/update-check.js +223 -0
  22. package/dist/migrations/index.d.ts +23 -0
  23. package/dist/migrations/index.js +22 -0
  24. package/dist/migrations/manifest.json +1 -0
  25. package/dist/scripts/build.js +7 -0
  26. package/dist/scripts/cli.js +33 -7
  27. package/dist/scripts/doctor.js +51 -9
  28. package/dist/scripts/go.mjs +43 -11
  29. package/dist/scripts/heal-evidence.mjs +149 -143
  30. package/dist/scripts/init.d.ts +9 -1
  31. package/dist/scripts/init.js +127 -23
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/reset.d.ts +1 -0
  35. package/dist/scripts/reset.js +233 -0
  36. package/dist/scripts/run.js +25 -24
  37. package/dist/scripts/upgrade.d.ts +1 -0
  38. package/dist/scripts/upgrade.js +256 -0
  39. package/dist/ui/server/app.js +18 -0
  40. package/dist/ui/server/index.js +3 -0
  41. package/dist/ui/server/update.d.ts +18 -0
  42. package/dist/ui/server/update.js +87 -0
  43. package/dist/ui/shared/types.d.ts +24 -0
  44. package/docs/agents/framework-dev.md +99 -1
  45. package/docs/commands.md +96 -9
  46. package/docs/install.md +92 -19
  47. package/docs/troubleshooting.md +43 -14
  48. package/package.json +35 -33
  49. package/templates/.claude/settings.json +5 -5
  50. package/templates/AGENTS.md +1 -0
  51. package/templates/CLAUDE.md +1 -1
  52. package/templates/gitignore +13 -13
  53. package/templates/package.json +33 -32
  54. package/templates/playwright.config.ts +3 -3
  55. package/templates/pnpm-workspace.yaml +8 -5
  56. package/templates/seed.spec.ts +9 -9
  57. package/templates/tsconfig.json +14 -14
  58. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
  59. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
  60. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
  61. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
  62. package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
  63. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
  64. package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
  65. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
  66. package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
  67. package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
  68. package/ui/dist/assets/index-Cy2l91qV.css +2 -0
  69. package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
  70. package/ui/dist/favicon.svg +11 -11
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/logos/claude.svg +1 -1
  73. package/ui/dist/logos/opencode.svg +1 -1
  74. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -1,19 +1,26 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
4
+ import { FRAMEWORK_ROOT, MARKER, PACKAGE, TEMPLATES_DIR, isRepoCheckout } from '../lib/paths.js';
5
+ import { migrationState, pendingMigrations } from '../lib/migrations.js';
6
+ import { MIGRATIONS } from '../migrations/index.js';
7
+ import { BUILD_APPROVALS, RUNTIME_EXTRAS, approveBuild, ensureRuntimeExtras, hasCapability, installedInWorkspace, } from '../lib/runtime-deps.js';
5
8
  /**
6
- * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
7
- * `--update` refreshes only the files generated from the framework templates.
9
+ * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns,
10
+ * `--update` refreshes only the files generated from the framework templates, and `--force` rewrites every file it
11
+ * writes (except the project's own data and, without `--reset-rules`, its rules).
8
12
  */
9
- const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
13
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--no-env] [--no-extras] [--install [spec]]
10
14
 
11
15
  --root <dir> the workspace to initialize (default: the current folder)
12
16
  --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
13
17
  .opencode/commands/*)
18
+ --force overwrite every file this tool writes, for a clean installation (keeps the project's own data:
19
+ profiles.yaml, package.json; AGENTS.md and CLAUDE.md need --reset-rules)
14
20
  --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
15
21
  --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
16
22
  --no-env do not write .env (ASRUN_ROOT)
23
+ --no-extras do not install the optional packages this machine lacks (ffmpeg-static), which "pnpm go" does
17
24
  --install [spec] also add the dependency (default "@asrunjs/asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
18
25
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
19
26
  export const GENERATED = [
@@ -30,7 +37,7 @@ export const GENERATED = [
30
37
  { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
31
38
  ];
32
39
  /** Files `init` creates once; after that the project owns them and may edit them freely. */
33
- const ONCE = [
40
+ export const ONCE = [
34
41
  { file: 'AGENTS.md', template: 'AGENTS.md' },
35
42
  { file: 'CLAUDE.md', template: 'CLAUDE.md' },
36
43
  { file: '.mcp.json', template: '.mcp.json' },
@@ -44,16 +51,13 @@ const ONCE = [
44
51
  ];
45
52
  // npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
46
53
  const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
47
- const MARKER = '.asrun.json';
48
- const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
49
- export const PACKAGE = '@asrunjs/asrun';
54
+ // Re-exported: `doctor` and `reset` import the marker name from here, and `lib/paths.ts` is the one definition.
55
+ export { MARKER };
56
+ export { PACKAGE };
57
+ export const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
50
58
  function readTemplate(name) {
51
59
  return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
52
60
  }
53
- /** True in the framework's own repository (not an installed package). */
54
- function isRepoCheckout() {
55
- return !FRAMEWORK_ROOT.split(path.sep).includes('node_modules') && fs.existsSync(path.join(FRAMEWORK_ROOT, 'examples', 'demo'));
56
- }
57
61
  /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
58
62
  export function frameworkPath(root) {
59
63
  if (!isRepoCheckout()) {
@@ -219,11 +223,25 @@ export async function main(args) {
219
223
  const installAt = args.indexOf('--install');
220
224
  const install = installAt >= 0;
221
225
  const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
222
- const forced = args
223
- .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
224
- .map((file) => file.split('\\').join('/'));
226
+ // `--force` alone overwrites every file init writes; `--force <path>` still names one file, so the messages the
227
+ // docs and doctor print keep working. The next token is a path only when it does not look like another flag.
228
+ const forced = [];
229
+ let forceAll = false;
230
+ for (let index = 0; index < args.length; index++) {
231
+ if (args[index] !== '--force')
232
+ continue;
233
+ const next = args[index + 1];
234
+ if (next !== undefined && !next.startsWith('-')) {
235
+ forced.push(next.split('\\').join('/'));
236
+ index++;
237
+ }
238
+ else {
239
+ forceAll = true;
240
+ }
241
+ }
225
242
  const resetRules = args.includes('--reset-rules');
226
243
  const skipEnv = args.includes('--no-env');
244
+ const skipExtras = args.includes('--no-extras');
227
245
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
228
246
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
229
247
  'asrun, or pass --root <dir> (the framework develops against examples/demo).');
@@ -234,8 +252,9 @@ export async function main(args) {
234
252
  const kept = [];
235
253
  const notes = [];
236
254
  const framework = frameworkPath(root);
237
- // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
238
- // `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need `--reset-rules` on top.
255
+ // `--force` repairs a file this tool writes; without a path it rewrites every one of them. A project's own rules
256
+ // are never overwritten by a framework update: `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need
257
+ // `--reset-rules` on top. The workspace's own data (`profiles.yaml`, `package.json`) is only replaced when named.
239
258
  const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
240
259
  const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
241
260
  for (const file of forced) {
@@ -259,16 +278,47 @@ export async function main(args) {
259
278
  written.push(entry.file);
260
279
  };
261
280
  for (const entry of GENERATED)
262
- place(entry, update || forced.includes(entry.file));
281
+ place(entry, update || forceAll || forced.includes(entry.file));
282
+ const keptProjectData = [];
283
+ const keptRules = [];
263
284
  for (const entry of ONCE) {
264
285
  // The demo workspace lives inside the framework repository: it has no package manager settings of its own.
265
286
  if (framework.startsWith('..') && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))
266
287
  continue;
267
- place(entry, forced.includes(entry.file));
288
+ const explicit = forced.includes(entry.file);
289
+ const projectData = entry.file === 'profiles.yaml' || entry.file === 'package.json';
290
+ const rules = projectRules(entry.file);
291
+ const overwrite = explicit || (forceAll && !projectData && (!rules || resetRules));
292
+ if (forceAll && !explicit && fs.existsSync(path.join(root, entry.file))) {
293
+ if (projectData)
294
+ keptProjectData.push(entry.file);
295
+ if (rules && !resetRules)
296
+ keptRules.push(entry.file);
297
+ }
298
+ place(entry, overwrite);
268
299
  }
269
- const gitignore = ensureGitignore(root, update);
300
+ const gitignore = ensureGitignore(root, update || forceAll);
270
301
  const merged = mergePackageJson(root);
271
302
  const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
303
+ // pnpm runs only the install scripts that pnpm-workspace.yaml approves. A file written before a package joined
304
+ // BUILD_APPROVALS (or one a project edited by hand) gets the missing entries here, before anything is installed:
305
+ // a note would be too late, the install that follows would already have skipped the script.
306
+ const approved = [];
307
+ const approvalsBroken = [];
308
+ if (!framework.startsWith('..')) {
309
+ for (const pkg of BUILD_APPROVALS) {
310
+ const result = approveBuild(root, pkg);
311
+ if (result === 'added' || result === 'created')
312
+ approved.push(pkg);
313
+ if (result === 'inline' || result === 'no-file')
314
+ approvalsBroken.push(pkg);
315
+ }
316
+ if (approvalsBroken.length > 0) {
317
+ notes.push(`pnpm-workspace.yaml has no "allowBuilds" block to extend: add ${approvalsBroken
318
+ .map((pkg) => `"${pkg}: true"`)
319
+ .join(', ')} under "allowBuilds" by hand, or pnpm skips its install script.`);
320
+ }
321
+ }
272
322
  if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
273
323
  const tsconfig = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8');
274
324
  if (tsconfig.includes('@asrun/')) {
@@ -277,8 +327,9 @@ export async function main(args) {
277
327
  }
278
328
  if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) && !written.includes('pnpm-workspace.yaml')) {
279
329
  const workspace = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
280
- if (!workspace.includes('allowBuilds')) {
281
- notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
330
+ if (!workspace.includes('minimumReleaseAgeExclude')) {
331
+ notes.push('Add an "@asrunjs/asrun" entry to "minimumReleaseAgeExclude" in pnpm-workspace.yaml, or pnpm installs the ' +
332
+ 'previous release for 24 hours after a new one.');
282
333
  }
283
334
  if (/^packages\s*:/m.test(workspace)) {
284
335
  notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
@@ -305,6 +356,15 @@ export async function main(args) {
305
356
  notes.push('Add "pnpm asrun *" to sandbox.excludedCommands in .claude/settings.json (see templates/.claude/settings.json).');
306
357
  }
307
358
  }
359
+ if (keptRules.length > 0) {
360
+ notes.push(`${keptRules.join(', ')} kept: the project owns them. Add --reset-rules to replace them with the template.`);
361
+ }
362
+ if (keptProjectData.length > 0) {
363
+ const profileHint = keptProjectData.includes('profiles.yaml')
364
+ ? ' Use "pnpm asrun init --force profiles.yaml" to replace the profile file.'
365
+ : '';
366
+ notes.push(`${keptProjectData.join(', ')} kept: --force never replaces the workspace's own data.${profileHint}`);
367
+ }
308
368
  // Install last, so pnpm-workspace.yaml (the build-script approval) already exists. Its absence is the reason
309
369
  // "pnpm add -D @asrunjs/asrun" fails on pnpm 12 with ERR_PNPM_IGNORED_BUILDS.
310
370
  const installed = isInstalled(root);
@@ -331,16 +391,54 @@ export async function main(args) {
331
391
  return 1;
332
392
  }
333
393
  }
394
+ // Optional packages stay out of the package's dependency graph (a runtime dependency with an install script makes
395
+ // "pnpm dlx @asrunjs/asrun" stop at pnpm's build-approval picker, which nobody can answer in a pipeline), so the
396
+ // workspace installs one here, only when this machine lacks what it provides. A failure never stops init.
397
+ const extrasInstalled = [];
398
+ const extrasSkipped = [];
399
+ if (!framework.startsWith('..') && isInstalled(root)) {
400
+ if (skipExtras) {
401
+ for (const extra of RUNTIME_EXTRAS) {
402
+ if (!hasCapability(extra) && !installedInWorkspace(root, extra.pkg))
403
+ extrasSkipped.push(extra.pkg);
404
+ }
405
+ if (extrasSkipped.length > 0) {
406
+ notes.push(`Optional packages were not installed (--no-extras): ${extrasSkipped.join(', ')}. Run "pnpm asrun init" ` +
407
+ 'without --no-extras, or "pnpm go", when the machine needs them.');
408
+ }
409
+ }
410
+ else {
411
+ for (const outcome of ensureRuntimeExtras(root, (extra) => console.log(`\n Installing ${extra.pkg} (${extra.provides})`))) {
412
+ if (outcome.status === 'installed')
413
+ extrasInstalled.push(outcome.extra.pkg);
414
+ else if (outcome.status === 'failed') {
415
+ notes.push(`${outcome.extra.pkg} was not installed (${outcome.detail}): ${outcome.extra.provides} is skipped until ` +
416
+ `it is. Run "pnpm add -D ${outcome.extra.pkg}" in ${root}, then "pnpm asrun init" again.`);
417
+ }
418
+ }
419
+ }
420
+ }
334
421
  const version = frameworkVersion();
422
+ const previous = readManifest(root);
423
+ // The marker records the installed version, but the migration baseline is the release the workspace was last
424
+ // migrated *from*. It only moves when a migration succeeds, so a manual "pnpm add" followed by "init --update"
425
+ // cannot erase what still has to be done.
426
+ const migrations = migrationState(previous, version.version);
335
427
  fs.writeFileSync(path.join(root, MARKER), JSON.stringify({
336
428
  framework: PACKAGE,
337
429
  frameworkPath: framework,
338
430
  version: version.version,
339
431
  commit: version.commit,
432
+ migrations,
340
433
  generated: GENERATED.map((entry) => entry.file),
341
434
  updatedAt: new Date().toISOString(),
342
435
  }, null, 2) + '\n');
343
- const label = firstRun ? 'Initialized' : update ? 'Refreshed' : 'Checked';
436
+ const pending = pendingMigrations(MIGRATIONS, migrations.since, version.version);
437
+ if (pending.length > 0) {
438
+ notes.push(`${pending.length} workspace migration(s) apply (${pending.map((migration) => migration.id).join(', ')}): ` +
439
+ 'run "pnpm asrun migrate".');
440
+ }
441
+ const label = firstRun ? 'Initialized' : forceAll ? 'Forced' : update ? 'Refreshed' : 'Checked';
344
442
  console.log(`${label} workspace: ${root}`);
345
443
  console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
346
444
  if (written.length > 0)
@@ -349,6 +447,12 @@ export async function main(args) {
349
447
  console.log(` kept: ${kept.join(', ')}`);
350
448
  if (merged.length > 0)
351
449
  console.log(` merged: package.json -> ${merged.join(', ')}`);
450
+ // A file init just wrote already has the entries; only an existing one is worth reporting as changed.
451
+ if (approved.length > 0 && !written.includes('pnpm-workspace.yaml')) {
452
+ console.log(` allowBuilds: added ${approved.join(', ')} to pnpm-workspace.yaml`);
453
+ }
454
+ if (extrasInstalled.length > 0)
455
+ console.log(` extras: installed ${extrasInstalled.join(', ')}`);
352
456
  if (gitignore !== 'kept')
353
457
  console.log(` .gitignore: ${gitignore}`);
354
458
  if (dotenv !== 'skipped')
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): Promise<number>;
@@ -0,0 +1,259 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
5
+ import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, writeMigrationState, } from '../lib/migrations.js';
6
+ import { MIGRATIONS } from '../migrations/index.js';
7
+ /**
8
+ * `pnpm asrun migrate`: brings an existing workspace up to the release that is installed, by running the migrations
9
+ * recorded in `migrations/index.ts`. `pnpm asrun upgrade` runs it after the install; it is also the command a person
10
+ * runs after a manual `pnpm add -D @asrunjs/asrun@latest`, or to see what is still open.
11
+ *
12
+ * It writes only what a migration describes, plus the migration state in `.asrun.json`. It never installs anything:
13
+ * a migration whose release is newer than the installed package does not run.
14
+ */
15
+ const USAGE = `Usage: pnpm asrun migrate [--root <dir>] [--list] [--dry-run] [--yes] [--from <version>] [--to <version>]
16
+ [--ack <id> ...|--ack-all]
17
+
18
+ --root <dir> the workspace to migrate (default: the current workspace)
19
+ --list list every migration of the installed release and its state, and change nothing
20
+ --dry-run show the migrations that would run, and change nothing
21
+ --yes apply breaking migrations without the confirmation prompt (for unattended runs)
22
+ --from <ver> pretend the workspace is at that release (default: the recorded migrations.since)
23
+ --to <ver> migrate up to that release (default: the installed one; never beyond it)
24
+ --ack <id> mark the manual steps of that migration as done (repeatable)
25
+ --ack-all mark every outstanding manual step as done
26
+
27
+ The migration baseline lives in .asrun.json ("migrations.since"). It only advances when a migration succeeds, so an
28
+ interrupted or failed run can simply be repeated.`;
29
+ /** The version of the package this command runs from: never beyond it, because its code is what applies migrations. */
30
+ function installedVersion() {
31
+ try {
32
+ const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
33
+ return typeof manifest.version === 'string' ? manifest.version : '0.0.0';
34
+ }
35
+ catch {
36
+ return '0.0.0';
37
+ }
38
+ }
39
+ /** The value of a flag that takes one; undefined when the flag is absent or has no value. */
40
+ function flagValue(args, name) {
41
+ const index = args.indexOf(name);
42
+ return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
43
+ }
44
+ /** Every `--ack <id>` of the command line. */
45
+ function acknowledged(args) {
46
+ const ids = [];
47
+ for (let index = 0; index < args.length; index++) {
48
+ if (args[index] === '--ack' && args[index + 1] !== undefined && !args[index + 1].startsWith('-')) {
49
+ ids.push(args[index + 1]);
50
+ index++;
51
+ }
52
+ }
53
+ return ids;
54
+ }
55
+ function describe(migration) {
56
+ return `${migration.version} ${migration.id} ${migration.summary}`;
57
+ }
58
+ /** Asks a person to confirm; without a terminal it refuses instead of assuming an answer. */
59
+ async function confirm(question) {
60
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
61
+ return false;
62
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
63
+ try {
64
+ return (await rl.question(question)).trim().toLowerCase() === 'migrate';
65
+ }
66
+ finally {
67
+ rl.close();
68
+ }
69
+ }
70
+ function printManual(ids) {
71
+ if (ids.length === 0)
72
+ return;
73
+ console.log('\nManual steps left (do them, then mark them done with "pnpm asrun migrate --ack <id>"):');
74
+ for (const id of ids) {
75
+ const migration = MIGRATIONS.find((entry) => entry.id === id);
76
+ if (migration === undefined) {
77
+ console.log(` ${id}`);
78
+ continue;
79
+ }
80
+ console.log(` ${migration.version} ${migration.id}: ${migration.summary}`);
81
+ for (const line of migration.manual ?? [])
82
+ console.log(` - ${line}`);
83
+ }
84
+ }
85
+ export async function main(args) {
86
+ if (args.includes('--help')) {
87
+ console.log(USAGE);
88
+ return 0;
89
+ }
90
+ const root = path.resolve(flagValue(args, '--root') ?? ROOT);
91
+ const dryRun = args.includes('--dry-run');
92
+ const yes = args.includes('--yes');
93
+ if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
94
+ console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun migrate" in the project that uses ' +
95
+ 'asrun, or pass --root <dir>.');
96
+ return 1;
97
+ }
98
+ const marker = readMarker(root);
99
+ if (marker === null) {
100
+ console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
101
+ return 1;
102
+ }
103
+ const installed = installedVersion();
104
+ const state = migrationState(marker, installed);
105
+ // `--list` answers "what does this release contain, and where is this workspace", without touching anything.
106
+ if (args.includes('--list')) {
107
+ console.log(`Workspace: ${root}`);
108
+ console.log(`asrun: ${installed} installed, migrations since ${state.since}`);
109
+ if (MIGRATIONS.length === 0) {
110
+ console.log('\nThis release has no migrations.');
111
+ }
112
+ else {
113
+ console.log(`\n${MIGRATIONS.length} migration(s) known to this release:`);
114
+ for (const migration of MIGRATIONS) {
115
+ const applied = compareVersions(migration.version, state.since) <= 0;
116
+ const mark = applied ? 'ok ' : migration.manual !== undefined && migration.manual.length > 0 ? '-> ' : ' ';
117
+ console.log(` ${mark}${describe(migration)}`);
118
+ console.log(` ${applied ? 'already at or before the baseline' : 'pending'}${migration.breaking ? ', breaking' : ''}`);
119
+ }
120
+ }
121
+ printManual(state.manual);
122
+ return 0;
123
+ }
124
+ const ackAll = args.includes('--ack-all');
125
+ const ackIds = acknowledged(args);
126
+ if (ackAll || ackIds.length > 0) {
127
+ const unknown = ackIds.filter((id) => !state.manual.includes(id));
128
+ if (unknown.length > 0) {
129
+ console.error(`Not outstanding: ${unknown.join(', ')}. Run "pnpm asrun migrate --list" to see what is.`);
130
+ return 1;
131
+ }
132
+ const manual = ackAll ? [] : state.manual.filter((id) => !ackIds.includes(id));
133
+ writeMigrationState(root, { since: state.since, manual });
134
+ console.log(ackAll
135
+ ? `Acknowledged every outstanding manual step (${state.manual.length}).`
136
+ : `Acknowledged: ${ackIds.join(', ')}.`);
137
+ printManual(manual);
138
+ return 0;
139
+ }
140
+ const from = flagValue(args, '--from') ?? state.since;
141
+ const to = flagValue(args, '--to') ?? installed;
142
+ if (!isVersion(from) || !isVersion(to)) {
143
+ console.error(`Not a version: ${!isVersion(from) ? from : to}`);
144
+ return 1;
145
+ }
146
+ if (compareVersions(to, installed) > 0) {
147
+ console.error(`Refusing to migrate to ${to}: the installed package is ${installed}. The code that applies a migration is the ` +
148
+ 'installed one, so run "pnpm asrun upgrade" (or "pnpm add -D @asrunjs/asrun@latest") first.');
149
+ return 1;
150
+ }
151
+ // A baseline recorded further than the installed release means the workspace was set up by a newer package (or the
152
+ // install went backwards). Nothing to apply; say so instead of silently moving the baseline back.
153
+ if (compareVersions(to, state.since) <= 0) {
154
+ console.log(`Workspace: ${root}`);
155
+ console.log(`Migrations: none — the baseline is ${state.since}, the installed release is ${installed}.`);
156
+ printManual(state.manual);
157
+ return 0;
158
+ }
159
+ // `--from` may only move the start forward: migrations the workspace's baseline already covers never run again.
160
+ const start = compareVersions(from, state.since) < 0 ? state.since : from;
161
+ const pending = pendingMigrations(MIGRATIONS, start, to);
162
+ console.log(`Workspace: ${root}`);
163
+ console.log(`asrun: ${installed}${installed === to ? '' : ` (migrating to ${to})`}`);
164
+ if (pending.length === 0) {
165
+ console.log(`Migrations: none between ${start} and ${to}.`);
166
+ // Nothing was skipped, so the baseline moves up to the release that was asked for: a workspace with an empty
167
+ // registry (or a release without migrations) then stops reporting the old baseline in `doctor`.
168
+ if (!dryRun)
169
+ writeMigrationState(root, { since: to, manual: state.manual });
170
+ printManual(state.manual);
171
+ return 0;
172
+ }
173
+ console.log(`Migrations: ${pending.length} between ${start} and ${to}`);
174
+ for (const migration of pending) {
175
+ console.log(` ${describe(migration)}${migration.auto === true ? ' (automatic)' : ''}${migration.breaking ? ' (breaking)' : ''}`);
176
+ if (migration.appliesTo !== undefined)
177
+ console.log(` affects: ${migration.appliesTo}`);
178
+ for (const line of migration.manual ?? [])
179
+ console.log(` - ${line}`);
180
+ }
181
+ if (dryRun) {
182
+ console.log('\nNothing was applied (--dry-run).');
183
+ return 0;
184
+ }
185
+ const automatic = pending.filter((migration) => migration.auto === true && typeof migration.apply === 'function');
186
+ const breaking = automatic.filter((migration) => migration.breaking === true);
187
+ if (breaking.length > 0 && !yes) {
188
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
189
+ console.error(`\nRefusing to apply ${breaking.length} breaking migration(s): there is no terminal to confirm in. ` +
190
+ 'A person adds --yes to do it.');
191
+ return 1;
192
+ }
193
+ const answer = await confirm(`\nType "migrate" to apply ${breaking.length} breaking migration(s): `);
194
+ if (!answer) {
195
+ console.error('Cancelled. Nothing was applied.');
196
+ return 1;
197
+ }
198
+ }
199
+ const context = {
200
+ root,
201
+ frameworkRoot: FRAMEWORK_ROOT,
202
+ from: start,
203
+ to,
204
+ log: (text) => console.log(` ${text}`),
205
+ };
206
+ const state2 = { since: state.since, manual: [...state.manual] };
207
+ // The baseline moves one *release* at a time: several migrations may come with the same version, and a failure in
208
+ // the middle of one release must leave the whole release pending, so a retry repeats it instead of skipping the rest.
209
+ let done = state.since;
210
+ let applied = 0;
211
+ let skipped = 0;
212
+ for (let index = 0; index < pending.length; index++) {
213
+ const migration = pending[index];
214
+ const label = `${migration.version} ${migration.id}`;
215
+ const lastOfRelease = index === pending.length - 1 || pending[index + 1].version !== migration.version;
216
+ if (migration.auto !== true || typeof migration.apply !== 'function') {
217
+ console.log(` -> ${label}: a person has to do this`);
218
+ for (const line of migration.manual ?? [])
219
+ console.log(` - ${line}`);
220
+ if ((migration.manual ?? []).length > 0)
221
+ state2.manual.push(migration.id);
222
+ if (lastOfRelease)
223
+ done = migration.version;
224
+ continue;
225
+ }
226
+ if (migration.detect !== undefined && !migration.detect(context)) {
227
+ console.log(` ok ${label}: already in place`);
228
+ skipped++;
229
+ if (lastOfRelease)
230
+ done = migration.version;
231
+ continue;
232
+ }
233
+ console.log(` .. ${label}`);
234
+ try {
235
+ await migration.apply(context);
236
+ }
237
+ catch (error) {
238
+ // The baseline stops at the last release that finished, so fixing the cause and running the command again
239
+ // repeats this release (its migrations must be idempotent) and nothing later.
240
+ writeMigrationState(root, { since: done, manual: [...new Set(state2.manual)] });
241
+ console.error(`\n ! ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
242
+ console.error(` The workspace is migrated up to ${done}. Fix the cause and run "pnpm asrun migrate" again.`);
243
+ return 1;
244
+ }
245
+ console.log(` ok ${label}`);
246
+ applied++;
247
+ if (lastOfRelease)
248
+ done = migration.version;
249
+ }
250
+ if (compareVersions(to, done) > 0)
251
+ done = to;
252
+ state2.since = done;
253
+ const manual = [...new Set(state2.manual)];
254
+ writeMigrationState(root, { since: state2.since, manual });
255
+ console.log(`\nApplied ${applied} migration(s)${skipped > 0 ? `, ${skipped} already in place` : ''}. ` +
256
+ `Baseline: ${state2.since}.`);
257
+ printManual(manual);
258
+ return 0;
259
+ }
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): Promise<number>;