@asrunjs/asrun 0.14.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.
- package/.claude/commands/explore-case.md +8 -3
- package/.claude/skills/exploring-cases/SKILL.md +47 -4
- package/.claude/skills/exploring-cases/references/notes.md +42 -4
- package/.claude/skills/generating-specs/SKILL.md +10 -3
- package/AGENTS.md +9 -2
- package/README.md +13 -1
- package/dist/lib/explore-notes.d.ts +15 -0
- package/dist/lib/explore-notes.js +34 -1
- package/dist/lib/migrations.d.ts +43 -4
- package/dist/lib/migrations.js +39 -7
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +220 -0
- package/dist/lib/term.d.ts +36 -0
- package/dist/lib/term.js +77 -0
- package/dist/lib/update-check.d.ts +1 -7
- package/dist/lib/update-check.js +3 -15
- package/dist/lib/upgrade-prompt.d.ts +84 -0
- package/dist/lib/upgrade-prompt.js +233 -0
- package/dist/migrations/index.d.ts +12 -4
- package/dist/migrations/index.js +40 -5
- package/dist/migrations/manifest.json +27 -1
- package/dist/scripts/build.js +4 -1
- package/dist/scripts/cli.js +2 -1
- package/dist/scripts/doctor.js +34 -3
- package/dist/scripts/explore-notes.js +19 -2
- package/dist/scripts/init.d.ts +6 -26
- package/dist/scripts/init.js +82 -101
- package/dist/scripts/migrate.js +84 -21
- package/dist/scripts/migrations-check.d.ts +1 -0
- package/dist/scripts/migrations-check.js +272 -0
- package/dist/scripts/migrations-freeze.d.ts +1 -0
- package/dist/scripts/migrations-freeze.js +126 -0
- package/dist/scripts/upgrade.js +53 -10
- package/dist/ui/server/app.js +4 -0
- package/dist/ui/shared/types.d.ts +6 -0
- package/docs/agents/case-format.md +6 -0
- package/docs/agents/framework-dev.md +44 -5
- package/docs/ai-workflow.md +16 -3
- package/docs/commands.md +53 -14
- package/docs/install.md +17 -4
- package/docs/web-ui.md +16 -4
- package/package.json +4 -1
- package/templates/.claude/commands/explore-case.md +6 -1
- package/templates/.claude/commands/upgrade-workspace.md +21 -0
- package/templates/AGENTS.md +1 -1
- package/ui/dist/assets/{dist-BlFnpAOk.js → dist-B4pM62Hc.js} +1 -1
- package/ui/dist/assets/{dist-BzDmxZ9V.js → dist-BFADGd4p.js} +1 -1
- package/ui/dist/assets/{dist-DxlXH2E-.js → dist-Bd-TmWTD.js} +1 -1
- package/ui/dist/assets/{dist-CjFzi6xl.js → dist-Bt053MSo.js} +1 -1
- package/ui/dist/assets/{dist-BwXTr_k4.js → dist-CD9RixNE.js} +1 -1
- package/ui/dist/assets/{dist-C1uAVsF-.js → dist-ChACtLiA.js} +1 -1
- package/ui/dist/assets/{dist-D7YBl9nc.js → dist-CiW9sEPF.js} +1 -1
- package/ui/dist/assets/{dist-jGvx4PBr.js → dist-CxEpzoZU.js} +1 -1
- package/ui/dist/assets/{dist-DxeJFPaO.js → dist-Dh7um7By.js} +1 -1
- package/ui/dist/assets/{dist-fpvlcqX7.js → dist-gHwKkZ6g.js} +1 -1
- package/ui/dist/assets/{index-Djsj43vv.js → index-Ckax27uf.js} +157 -157
- package/ui/dist/index.html +1 -1
package/dist/scripts/init.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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".');
|
package/dist/scripts/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
+
console.log('\nManual steps left (do them, then record that they are done):');
|
|
74
85
|
for (const id of ids) {
|
|
75
|
-
const migration =
|
|
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 (
|
|
146
|
+
if (registry.length === 0) {
|
|
110
147
|
console.log('\nThis release has no migrations.');
|
|
111
148
|
}
|
|
112
149
|
else {
|
|
113
|
-
console.log(`\n${
|
|
114
|
-
for (const migration of
|
|
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
|
-
|
|
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(
|
|
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(`\
|
|
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;
|