@asrunjs/asrun 0.13.0 → 0.15.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 (78) hide show
  1. package/.claude/commands/explore-case.md +8 -3
  2. package/.claude/skills/exploring-cases/SKILL.md +72 -14
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
  4. package/.claude/skills/exploring-cases/references/notes.md +42 -4
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/exploring-cases/references/readiness.md +6 -0
  7. package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
  8. package/.claude/skills/generating-specs/SKILL.md +10 -3
  9. package/AGENTS.md +12 -4
  10. package/README.md +14 -2
  11. package/dist/lib/desktop-notify.d.ts +38 -0
  12. package/dist/lib/desktop-notify.js +158 -0
  13. package/dist/lib/explore-notes.d.ts +15 -0
  14. package/dist/lib/explore-notes.js +34 -1
  15. package/dist/lib/migrations.d.ts +43 -4
  16. package/dist/lib/migrations.js +39 -7
  17. package/dist/lib/paths.d.ts +9 -2
  18. package/dist/lib/paths.js +32 -5
  19. package/dist/lib/pdf.js +23 -3
  20. package/dist/lib/scaffold.d.ts +87 -0
  21. package/dist/lib/scaffold.js +220 -0
  22. package/dist/lib/term.d.ts +36 -0
  23. package/dist/lib/term.js +77 -0
  24. package/dist/lib/update-check.d.ts +1 -7
  25. package/dist/lib/update-check.js +3 -15
  26. package/dist/lib/upgrade-prompt.d.ts +84 -0
  27. package/dist/lib/upgrade-prompt.js +233 -0
  28. package/dist/migrations/index.d.ts +12 -4
  29. package/dist/migrations/index.js +40 -5
  30. package/dist/migrations/manifest.json +27 -1
  31. package/dist/scripts/build.js +4 -1
  32. package/dist/scripts/check-cases.js +3 -3
  33. package/dist/scripts/cli.js +11 -3
  34. package/dist/scripts/doctor.js +73 -4
  35. package/dist/scripts/explore-notes.js +19 -2
  36. package/dist/scripts/init.d.ts +6 -26
  37. package/dist/scripts/init.js +82 -101
  38. package/dist/scripts/migrate.js +84 -21
  39. package/dist/scripts/migrations-check.d.ts +1 -0
  40. package/dist/scripts/migrations-check.js +272 -0
  41. package/dist/scripts/migrations-freeze.d.ts +1 -0
  42. package/dist/scripts/migrations-freeze.js +126 -0
  43. package/dist/scripts/notify-test.d.ts +1 -0
  44. package/dist/scripts/notify-test.js +48 -0
  45. package/dist/scripts/upgrade.js +53 -10
  46. package/dist/ui/server/actions.js +4 -2
  47. package/dist/ui/server/ai-stream.d.ts +7 -1
  48. package/dist/ui/server/ai-stream.js +26 -7
  49. package/dist/ui/server/app.js +4 -0
  50. package/dist/ui/server/jobs.d.ts +10 -0
  51. package/dist/ui/server/jobs.js +41 -1
  52. package/dist/ui/shared/ai-commands.js +4 -2
  53. package/dist/ui/shared/types.d.ts +8 -0
  54. package/docs/agents/case-format.md +8 -1
  55. package/docs/agents/framework-dev.md +44 -5
  56. package/docs/ai-workflow.md +27 -10
  57. package/docs/cases.md +5 -5
  58. package/docs/commands.md +73 -15
  59. package/docs/install.md +18 -5
  60. package/docs/troubleshooting.md +17 -0
  61. package/docs/web-ui.md +23 -8
  62. package/package.json +5 -2
  63. package/templates/.claude/commands/explore-case.md +9 -3
  64. package/templates/.claude/commands/upgrade-workspace.md +21 -0
  65. package/templates/AGENTS.md +1 -1
  66. package/templates/package.json +1 -1
  67. package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
  68. package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
  69. package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
  70. package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
  71. package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
  72. package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
  73. package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
  74. package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
  75. package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
  76. package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
  77. package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
  78. package/ui/dist/index.html +1 -1
@@ -1,8 +1,8 @@
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, MARKER, PACKAGE, TEMPLATES_DIR, isRepoCheckout } from '../lib/paths.js';
5
- import { migrationState, pendingMigrations } from '../lib/migrations.js';
4
+ import { FRAMEWORK_ROOT, MARKER, PACKAGE, TEMPLATES_DIR } from '../lib/paths.js';
5
+ import { migrationState, pendingMigrations, resolveMigrations } from '../lib/migrations.js';
6
6
  import { MIGRATIONS } from '../migrations/index.js';
7
7
  import { BUILD_APPROVALS, RUNTIME_EXTRAS, approveBuild, ensureRuntimeExtras, hasCapability, installedInWorkspace, } from '../lib/runtime-deps.js';
8
8
  /**
@@ -10,7 +10,7 @@ import { BUILD_APPROVALS, RUNTIME_EXTRAS, approveBuild, ensureRuntimeExtras, has
10
10
  * `--update` refreshes only the files generated from the framework templates, and `--force` rewrites every file it
11
11
  * writes (except the project's own data and, without `--reset-rules`, its rules).
12
12
  */
13
- const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--no-env] [--no-extras] [--install [spec]]
13
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--ack <file>...] [--no-env] [--no-extras] [--install [spec]]
14
14
 
15
15
  --root <dir> the workspace to initialize (default: the current folder)
16
16
  --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
@@ -19,111 +19,26 @@ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>
19
19
  profiles.yaml, package.json; AGENTS.md and CLAUDE.md need --reset-rules)
20
20
  --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
21
21
  --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
22
+ --ack <path> record that a project-owned file is aligned with the template of the installed release, without
23
+ touching the file (after a merge); repeatable. Takes one of: ${ONCE.map((entry) => entry.file).join(', ')}
22
24
  --no-env do not write .env (ASRUN_ROOT)
23
25
  --no-extras do not install the optional packages this machine lacks (ffmpeg-static), which "pnpm go" does
24
- --install [spec] also add the dependency (default "@asrunjs/asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
25
- /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
26
- export const GENERATED = [
27
- { file: 'playwright.config.ts', template: 'playwright.config.ts' },
28
- { file: 'tests/seed.spec.ts', template: 'seed.spec.ts' },
29
- { file: '.claude/commands/make-case.md', template: '.claude/commands/make-case.md' },
30
- { file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
31
- { file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
32
- { file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
33
- // OpenCode does not read `.claude/commands` (only `.claude/skills`), so the same commands live here too.
34
- { file: '.opencode/commands/make-case.md', template: '.claude/commands/make-case.md' },
35
- { file: '.opencode/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
36
- { file: '.opencode/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
37
- { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
38
- ];
39
- /** Files `init` creates once; after that the project owns them and may edit them freely. */
40
- export const ONCE = [
41
- { file: 'AGENTS.md', template: 'AGENTS.md' },
42
- { file: 'CLAUDE.md', template: 'CLAUDE.md' },
43
- { file: '.mcp.json', template: '.mcp.json' },
44
- { file: 'opencode.jsonc', template: 'opencode.jsonc' },
45
- { file: '.claude/settings.json', template: '.claude/settings.json' },
46
- { file: 'tsconfig.json', template: 'tsconfig.json' },
47
- { file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
48
- { file: 'profiles.yaml', template: 'profiles.yaml' },
49
- { file: 'cases/_template.md', template: 'cases/_template.md' },
50
- { file: 'package.json', template: 'package.json' },
51
- ];
26
+ --install [spec] also add the dependency (default "@asrunjs/asrun"; e.g. --install github:<owner>/<repo>#<tag>)
27
+
28
+ Most of the time the upgrade prompt is better than running these by hand: "pnpm asrun upgrade --prompt".`;
29
+ /**
30
+ * The files `init` writes come from `lib/scaffold.ts`, together with the baseline that records which template a
31
+ * project-owned file is aligned with. They are re-exported here because `doctor`, `reset` and the prompt import them
32
+ * from the command that writes them.
33
+ */
34
+ export { GENERATED, ONCE, frameworkPath, frameworkVersion, isInstalled, renderTemplate } from '../lib/scaffold.js';
35
+ import { GENERATED, ONCE, ackScaffold, frameworkPath, frameworkVersion, isInstalled, renderTemplate, scaffoldRecords, templateHash, } from '../lib/scaffold.js';
52
36
  // npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
53
37
  const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
54
38
  // Re-exported: `doctor` and `reset` import the marker name from here, and `lib/paths.ts` is the one definition.
55
39
  export { MARKER };
56
40
  export { PACKAGE };
57
41
  export const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
58
- function readTemplate(name) {
59
- return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
60
- }
61
- /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
62
- export function frameworkPath(root) {
63
- if (!isRepoCheckout()) {
64
- // The standard install location, or a hoisted one in a monorepo.
65
- for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
66
- const candidate = path.join(dir, 'node_modules', PACKAGE);
67
- if (fs.existsSync(path.join(candidate, 'package.json'))) {
68
- const relative = path.relative(root, candidate).split(path.sep).join('/');
69
- return relative.startsWith('.') ? relative : `./${relative}`;
70
- }
71
- if (path.dirname(dir) === dir)
72
- break;
73
- }
74
- // Not installed yet (a scaffolding run): name the place the package will live.
75
- return `./node_modules/${PACKAGE}`;
76
- }
77
- const relative = path.relative(root, FRAMEWORK_ROOT).split(path.sep).join('/');
78
- if (relative === '')
79
- return '.';
80
- return relative.startsWith('.') ? relative : `./${relative}`;
81
- }
82
- /** True when this workspace has the package installed (or is the framework's own checkout). */
83
- export function isInstalled(root) {
84
- return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
85
- }
86
- /** The range the workspace's `package.json` names for the framework: `^` plus the version that wrote the workspace. */
87
- let asrunRange;
88
- function asrunDependencyRange() {
89
- asrunRange ??= `^${frameworkVersion().version}`;
90
- return asrunRange;
91
- }
92
- /** Fills the `{{framework}}`, `{{playwright}}` and `{{asrun}}` placeholders of a template. */
93
- export function renderTemplate(name, root) {
94
- return readTemplate(name)
95
- .replaceAll('{{framework}}', frameworkPath(root))
96
- .replaceAll('{{playwright}}', playwrightBin(root))
97
- .replaceAll('{{asrun}}', asrunDependencyRange());
98
- }
99
- /**
100
- * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
101
- * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
102
- * Kept relative: the file is committed and must not name a machine.
103
- */
104
- export function playwrightBin(root) {
105
- if (fs.existsSync(path.join(root, 'node_modules', '.bin', 'playwright')))
106
- return './node_modules/.bin/playwright';
107
- const framework = frameworkPath(root);
108
- return framework.startsWith('..') ? `${framework}/node_modules/.bin/playwright` : './node_modules/.bin/playwright';
109
- }
110
- export function frameworkVersion() {
111
- let version = '0.0.0';
112
- try {
113
- version = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8')).version ?? version;
114
- }
115
- catch {
116
- // keep the default
117
- }
118
- let commit = 'unknown';
119
- try {
120
- commit = spawnSync('git', ['-C', FRAMEWORK_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).stdout.trim() || 'unknown';
121
- }
122
- catch {
123
- // keep the default
124
- }
125
- return { version, commit };
126
- }
127
42
  /** True when any dependency block of the manifest already names `name`, whatever the range. */
128
43
  function declaredIn(manifest, name) {
129
44
  return [manifest.dependencies, manifest.devDependencies, manifest.optionalDependencies, manifest.peerDependencies].some((block) => block?.[name] !== undefined);
@@ -242,13 +157,47 @@ export async function main(args) {
242
157
  const resetRules = args.includes('--reset-rules');
243
158
  const skipEnv = args.includes('--no-env');
244
159
  const skipExtras = args.includes('--no-extras');
160
+ // `--ack <file>`: the project merged the template's part by hand and says so. It never writes the file.
161
+ const acknowledged = [];
162
+ for (let index = 0; index < args.length; index++) {
163
+ if (args[index] !== '--ack')
164
+ continue;
165
+ const next = args[index + 1];
166
+ if (next !== undefined && !next.startsWith('-')) {
167
+ acknowledged.push(next.split('\\').join('/'));
168
+ index++;
169
+ }
170
+ }
245
171
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
246
172
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
247
173
  'asrun, or pass --root <dir> (the framework develops against examples/demo).');
248
174
  return 1;
249
175
  }
176
+ if (acknowledged.length > 0) {
177
+ const unknown = acknowledged.filter((file) => !ONCE.some((entry) => entry.file === file));
178
+ if (unknown.length > 0) {
179
+ console.error(`--ack ${unknown.join(', ')}: only a file init creates once can be acknowledged (${ONCE.map((entry) => entry.file).join(', ')}).`);
180
+ return 1;
181
+ }
182
+ const missing = acknowledged.filter((file) => !fs.existsSync(path.join(root, file)));
183
+ if (missing.length > 0) {
184
+ console.error(`--ack ${missing.join(', ')}: the file does not exist. Write it from the template first: ` +
185
+ `"pnpm asrun init --force ${missing[0]}".`);
186
+ return 1;
187
+ }
188
+ if (!fs.existsSync(path.join(root, MARKER))) {
189
+ console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
190
+ return 1;
191
+ }
192
+ const version = frameworkVersion().version;
193
+ const done = ackScaffold(root, acknowledged, version);
194
+ console.log(`Acknowledged ${done.join(', ')} as aligned with the ${version} templates.`);
195
+ console.log('Run "pnpm asrun upgrade --prompt" to see what is still outstanding.');
196
+ return 0;
197
+ }
250
198
  const firstRun = !fs.existsSync(path.join(root, MARKER));
251
199
  const written = [];
200
+ const writtenScaffold = [];
252
201
  const kept = [];
253
202
  const notes = [];
254
203
  const framework = frameworkPath(root);
@@ -276,6 +225,10 @@ export async function main(args) {
276
225
  fs.mkdirSync(path.dirname(target), { recursive: true });
277
226
  fs.writeFileSync(target, renderTemplate(entry.template, root));
278
227
  written.push(entry.file);
228
+ // The file now matches this release's template, so its baseline starts here: a later release can tell "nobody
229
+ // touched it" from "the project edited it".
230
+ if (ONCE.some((candidate) => candidate.file === entry.file))
231
+ writtenScaffold.push(entry.file);
279
232
  };
280
233
  for (const entry of GENERATED)
281
234
  place(entry, update || forceAll || forced.includes(entry.file));
@@ -424,16 +377,44 @@ export async function main(args) {
424
377
  // migrated *from*. It only moves when a migration succeeds, so a manual "pnpm add" followed by "init --update"
425
378
  // cannot erase what still has to be done.
426
379
  const migrations = migrationState(previous, version.version);
380
+ // The scaffold baseline: what each project-owned file is aligned with. A file this run wrote starts at this
381
+ // release's template; every other recorded file keeps its baseline, so a later release can still say whether the
382
+ // template moved under it. A file without a record (a workspace from before the baseline, or one the project edited
383
+ // after an init) gets one only when it already equals the template — anything else stays `unknown-base` until
384
+ // somebody merges it and runs "pnpm asrun init --ack <file>".
385
+ const scaffold = scaffoldRecords(previous);
386
+ for (const entry of ONCE) {
387
+ if (writtenScaffold.includes(entry.file)) {
388
+ scaffold[entry.file] = { version: version.version, template: templateHash(renderTemplate(entry.template, root)) };
389
+ continue;
390
+ }
391
+ if (scaffold[entry.file] !== undefined)
392
+ continue;
393
+ const file = path.join(root, entry.file);
394
+ if (!fs.existsSync(file))
395
+ continue;
396
+ const template = renderTemplate(entry.template, root);
397
+ if (fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') !== template.replace(/\r\n/g, '\n'))
398
+ continue;
399
+ scaffold[entry.file] = { version: version.version, template: templateHash(template) };
400
+ }
401
+ // The release the workspace came from, for the upgrade prompt ("0.14.0 -> 0.15.0"). Written only when it changed, so
402
+ // a second "init --update" does not erase it.
403
+ const previousVersion = (typeof previous?.version === 'string' && previous.version !== version.version
404
+ ? { version: previous.version, at: typeof previous.updatedAt === 'string' ? previous.updatedAt : new Date().toISOString() }
405
+ : previous?.previous);
427
406
  fs.writeFileSync(path.join(root, MARKER), JSON.stringify({
428
407
  framework: PACKAGE,
429
408
  frameworkPath: framework,
430
409
  version: version.version,
431
410
  commit: version.commit,
432
411
  migrations,
412
+ scaffold,
413
+ ...(previousVersion !== undefined && { previous: previousVersion }),
433
414
  generated: GENERATED.map((entry) => entry.file),
434
415
  updatedAt: new Date().toISOString(),
435
416
  }, null, 2) + '\n');
436
- const pending = pendingMigrations(MIGRATIONS, migrations.since, version.version);
417
+ const pending = pendingMigrations(resolveMigrations(MIGRATIONS, version.version), migrations.since, version.version);
437
418
  if (pending.length > 0) {
438
419
  notes.push(`${pending.length} workspace migration(s) apply (${pending.map((migration) => migration.id).join(', ')}): ` +
439
420
  'run "pnpm asrun migrate".');
@@ -2,8 +2,11 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline/promises';
4
4
  import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
5
- import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, writeMigrationState, } from '../lib/migrations.js';
5
+ import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, resolveMigrations, writeMigrationState, } from '../lib/migrations.js';
6
6
  import { MIGRATIONS } from '../migrations/index.js';
7
+ import { ackScaffold, scaffoldFiles } from '../lib/scaffold.js';
8
+ import { nextStepsText, upgradePrompt } from '../lib/upgrade-prompt.js';
9
+ import { useColor } from '../lib/term.js';
7
10
  /**
8
11
  * `pnpm asrun migrate`: brings an existing workspace up to the release that is installed, by running the migrations
9
12
  * recorded in `migrations/index.ts`. `pnpm asrun upgrade` runs it after the install; it is also the command a person
@@ -12,17 +15,20 @@ import { MIGRATIONS } from '../migrations/index.js';
12
15
  * It writes only what a migration describes, plus the migration state in `.asrun.json`. It never installs anything:
13
16
  * a migration whose release is newer than the installed package does not run.
14
17
  */
15
- const USAGE = `Usage: pnpm asrun migrate [--root <dir>] [--list] [--dry-run] [--yes] [--from <version>] [--to <version>]
16
- [--ack <id> ...|--ack-all]
18
+ const USAGE = `Usage: pnpm asrun migrate [--root <dir>] [--list] [--dry-run] [--yes|-y] [--from <version>] [--to <version>]
19
+ [--ack <id> ...|--ack-all] [--prompt [--json]]
17
20
 
18
21
  --root <dir> the workspace to migrate (default: the current workspace)
19
22
  --list list every migration of the installed release and its state, and change nothing
20
23
  --dry-run show the migrations that would run, and change nothing
21
- --yes apply breaking migrations without the confirmation prompt (for unattended runs)
24
+ --yes, -y apply breaking migrations without the confirmation prompt (for unattended runs)
22
25
  --from <ver> pretend the workspace is at that release (default: the recorded migrations.since)
23
26
  --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)
27
+ --ack <id> mark the manual steps of that migration as done (repeatable); a migration that names project files
28
+ also records them as aligned with this release's templates
25
29
  --ack-all mark every outstanding manual step as done
30
+ --prompt print the prompt that hands the leftover work to an AI agent, and change nothing
31
+ --json with --prompt: the same answer as JSON, for the web UI
26
32
 
27
33
  The migration baseline lives in .asrun.json ("migrations.since"). It only advances when a migration succeeds, so an
28
34
  interrupted or failed run can simply be repeated.`;
@@ -55,33 +61,49 @@ function acknowledged(args) {
55
61
  function describe(migration) {
56
62
  return `${migration.version} ${migration.id} ${migration.summary}`;
57
63
  }
58
- /** Asks a person to confirm; without a terminal it refuses instead of assuming an answer. */
64
+ /** Asks a person to confirm; an empty line or anything that is not "y"/"yes" means no. */
59
65
  async function confirm(question) {
60
66
  if (!process.stdin.isTTY || !process.stdout.isTTY)
61
67
  return false;
62
68
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
63
69
  try {
64
- return (await rl.question(question)).trim().toLowerCase() === 'migrate';
70
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
71
+ return answer === 'y' || answer === 'yes';
65
72
  }
66
73
  finally {
67
74
  rl.close();
68
75
  }
69
76
  }
70
- function printManual(ids) {
77
+ /**
78
+ * The manual steps still to do. Each one ends with the exact command that clears it: a placeholder `<id>` is what made
79
+ * people leave these open, because nothing in the output said which id to pass.
80
+ */
81
+ function printManual(ids, registry) {
71
82
  if (ids.length === 0)
72
83
  return;
73
- console.log('\nManual steps left (do them, then mark them done with "pnpm asrun migrate --ack <id>"):');
84
+ console.log('\nManual steps left (do them, then record that they are done):');
74
85
  for (const id of ids) {
75
- const migration = MIGRATIONS.find((entry) => entry.id === id);
86
+ const migration = registry.find((entry) => entry.id === id);
76
87
  if (migration === undefined) {
77
88
  console.log(` ${id}`);
89
+ console.log(` when done: pnpm asrun migrate --ack ${id}`);
78
90
  continue;
79
91
  }
80
92
  console.log(` ${migration.version} ${migration.id}: ${migration.summary}`);
81
93
  for (const line of migration.manual ?? [])
82
94
  console.log(` - ${line}`);
95
+ console.log(` when done: pnpm asrun migrate --ack ${migration.id}`);
83
96
  }
84
97
  }
98
+ /**
99
+ * The framed "what is left" block, the same one an upgrade prints: project files whose template moved under the
100
+ * workspace, plus the manual steps still open. It carries paste-ready commands and names the agent entry point.
101
+ */
102
+ function reportNext(root) {
103
+ const next = nextStepsText(root, { color: useColor(process.stdout) });
104
+ if (next !== null)
105
+ console.log(`\n${next}`);
106
+ }
85
107
  export async function main(args) {
86
108
  if (args.includes('--help')) {
87
109
  console.log(USAGE);
@@ -89,7 +111,9 @@ export async function main(args) {
89
111
  }
90
112
  const root = path.resolve(flagValue(args, '--root') ?? ROOT);
91
113
  const dryRun = args.includes('--dry-run');
92
- const yes = args.includes('--yes');
114
+ const yes = args.includes('--yes') || args.includes('-y');
115
+ const promptOnly = args.includes('--prompt');
116
+ const asJson = args.includes('--json');
93
117
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
94
118
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun migrate" in the project that uses ' +
95
119
  'asrun, or pass --root <dir>.');
@@ -101,24 +125,40 @@ export async function main(args) {
101
125
  return 1;
102
126
  }
103
127
  const installed = installedVersion();
128
+ // Entries still carrying the `unreleased` sentinel are resolved to the installed release before anything compares
129
+ // versions: this is the release whose code they came with, whatever the release pull request ended up numbering it.
130
+ const registry = resolveMigrations(MIGRATIONS, installed);
104
131
  const state = migrationState(marker, installed);
132
+ // `--prompt` is the same answer as "pnpm asrun upgrade --prompt": what is left, for an AI agent. It changes nothing.
133
+ if (promptOnly) {
134
+ const payload = upgradePrompt(root);
135
+ console.log(asJson ? JSON.stringify(payload, null, 2) : payload.prompt);
136
+ return 0;
137
+ }
138
+ if (asJson) {
139
+ console.error('--json belongs to --prompt: "pnpm asrun migrate --prompt --json".');
140
+ return 1;
141
+ }
105
142
  // `--list` answers "what does this release contain, and where is this workspace", without touching anything.
106
143
  if (args.includes('--list')) {
107
144
  console.log(`Workspace: ${root}`);
108
145
  console.log(`asrun: ${installed} installed, migrations since ${state.since}`);
109
- if (MIGRATIONS.length === 0) {
146
+ if (registry.length === 0) {
110
147
  console.log('\nThis release has no migrations.');
111
148
  }
112
149
  else {
113
- console.log(`\n${MIGRATIONS.length} migration(s) known to this release:`);
114
- for (const migration of MIGRATIONS) {
150
+ console.log(`\n${registry.length} migration(s) known to this release:`);
151
+ for (const migration of registry) {
115
152
  const applied = compareVersions(migration.version, state.since) <= 0;
116
153
  const mark = applied ? 'ok ' : migration.manual !== undefined && migration.manual.length > 0 ? '-> ' : ' ';
117
154
  console.log(` ${mark}${describe(migration)}`);
118
155
  console.log(` ${applied ? 'already at or before the baseline' : 'pending'}${migration.breaking ? ', breaking' : ''}`);
156
+ if (!applied && (migration.manual ?? []).length > 0) {
157
+ console.log(` when done: pnpm asrun migrate --ack ${migration.id}`);
158
+ }
119
159
  }
120
160
  }
121
- printManual(state.manual);
161
+ printManual(state.manual, registry);
122
162
  return 0;
123
163
  }
124
164
  const ackAll = args.includes('--ack-all');
@@ -131,10 +171,20 @@ export async function main(args) {
131
171
  }
132
172
  const manual = ackAll ? [] : state.manual.filter((id) => !ackIds.includes(id));
133
173
  writeMigrationState(root, { since: state.since, manual });
174
+ // A migration that names project files says they are aligned with this release's templates; acknowledging it is the
175
+ // same statement, so it advances their baseline too and the NEXT block stops naming them.
176
+ const files = scaffoldFiles(registry.filter((migration) => ackAll || ackIds.includes(migration.id)).flatMap((migration) => migration.files ?? []));
177
+ const acknowledgedFiles = files.length === 0 ? [] : ackScaffold(root, files, installed);
134
178
  console.log(ackAll
135
179
  ? `Acknowledged every outstanding manual step (${state.manual.length}).`
136
180
  : `Acknowledged: ${ackIds.join(', ')}.`);
137
- printManual(manual);
181
+ if (acknowledgedFiles.length > 0) {
182
+ console.log(`Aligned with the ${installed} templates: ${acknowledgedFiles.join(', ')}.`);
183
+ }
184
+ printManual(manual, registry);
185
+ const next = nextStepsText(root, { color: useColor(process.stdout) });
186
+ if (next !== null)
187
+ console.log(`\n${next}`);
138
188
  return 0;
139
189
  }
140
190
  const from = flagValue(args, '--from') ?? state.since;
@@ -153,12 +203,13 @@ export async function main(args) {
153
203
  if (compareVersions(to, state.since) <= 0) {
154
204
  console.log(`Workspace: ${root}`);
155
205
  console.log(`Migrations: none — the baseline is ${state.since}, the installed release is ${installed}.`);
156
- printManual(state.manual);
206
+ printManual(state.manual, registry);
207
+ reportNext(root);
157
208
  return 0;
158
209
  }
159
210
  // `--from` may only move the start forward: migrations the workspace's baseline already covers never run again.
160
211
  const start = compareVersions(from, state.since) < 0 ? state.since : from;
161
- const pending = pendingMigrations(MIGRATIONS, start, to);
212
+ const pending = pendingMigrations(registry, start, to);
162
213
  console.log(`Workspace: ${root}`);
163
214
  console.log(`asrun: ${installed}${installed === to ? '' : ` (migrating to ${to})`}`);
164
215
  if (pending.length === 0) {
@@ -167,7 +218,8 @@ export async function main(args) {
167
218
  // registry (or a release without migrations) then stops reporting the old baseline in `doctor`.
168
219
  if (!dryRun)
169
220
  writeMigrationState(root, { since: to, manual: state.manual });
170
- printManual(state.manual);
221
+ printManual(state.manual, registry);
222
+ reportNext(root);
171
223
  return 0;
172
224
  }
173
225
  console.log(`Migrations: ${pending.length} between ${start} and ${to}`);
@@ -177,6 +229,8 @@ export async function main(args) {
177
229
  console.log(` affects: ${migration.appliesTo}`);
178
230
  for (const line of migration.manual ?? [])
179
231
  console.log(` - ${line}`);
232
+ if ((migration.manual ?? []).length > 0)
233
+ console.log(` when done: pnpm asrun migrate --ack ${migration.id}`);
180
234
  }
181
235
  if (dryRun) {
182
236
  console.log('\nNothing was applied (--dry-run).');
@@ -190,7 +244,7 @@ export async function main(args) {
190
244
  'A person adds --yes to do it.');
191
245
  return 1;
192
246
  }
193
- const answer = await confirm(`\nType "migrate" to apply ${breaking.length} breaking migration(s): `);
247
+ const answer = await confirm(`\nApply ${breaking.length} breaking migration(s)?`);
194
248
  if (!answer) {
195
249
  console.error('Cancelled. Nothing was applied.');
196
250
  return 1;
@@ -213,6 +267,14 @@ export async function main(args) {
213
267
  const migration = pending[index];
214
268
  const label = `${migration.version} ${migration.id}`;
215
269
  const lastOfRelease = index === pending.length - 1 || pending[index + 1].version !== migration.version;
270
+ // An advisory entry says the release touched project files but asks nothing: report it as done so the baseline can
271
+ // move, and never leave a manual step behind (there is none).
272
+ if (migration.advisory === true) {
273
+ console.log(` ok ${label}: nothing for a workspace to do`);
274
+ if (lastOfRelease)
275
+ done = migration.version;
276
+ continue;
277
+ }
216
278
  if (migration.auto !== true || typeof migration.apply !== 'function') {
217
279
  console.log(` -> ${label}: a person has to do this`);
218
280
  for (const line of migration.manual ?? [])
@@ -254,6 +316,7 @@ export async function main(args) {
254
316
  writeMigrationState(root, { since: state2.since, manual });
255
317
  console.log(`\nApplied ${applied} migration(s)${skipped > 0 ? `, ${skipped} already in place` : ''}. ` +
256
318
  `Baseline: ${state2.since}.`);
257
- printManual(manual);
319
+ printManual(manual, registry);
320
+ reportNext(root);
258
321
  return 0;
259
322
  }
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): number;