@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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { FRAMEWORK_ROOT } from '../lib/paths.js';
|
|
5
|
+
import { compareVersions, isVersion, UNRELEASED } from '../lib/migrations.js';
|
|
6
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
7
|
+
import { GENERATED, ONCE } from '../lib/scaffold.js';
|
|
8
|
+
/**
|
|
9
|
+
* `pnpm migrations:check`: the release discipline of this repository, enforced instead of remembered.
|
|
10
|
+
*
|
|
11
|
+
* A change to something an *existing* workspace has to do something about needs a migration entry, and a migration
|
|
12
|
+
* entry needs the right shape. The check compares the working tree with the newest release tag and answers three
|
|
13
|
+
* questions: which surfaces changed, which entries cover them, and is every entry valid. It runs in CI on every pull
|
|
14
|
+
* request, so a forgotten entry stops the change instead of reaching a user as a silently stale workspace.
|
|
15
|
+
*/
|
|
16
|
+
const USAGE = `Usage: pnpm migrations:check [--base <ref>] [--json]
|
|
17
|
+
|
|
18
|
+
--base <ref> compare with this ref (default: the newest asrun-v* / v* tag)
|
|
19
|
+
--json print the result as JSON
|
|
20
|
+
|
|
21
|
+
Exit code 1 when a changed surface has no migration entry, or an entry is invalid.
|
|
22
|
+
An entry names "unreleased" or a release that already has a tag; the one exception is the version package.json
|
|
23
|
+
declares, which is the release an open release pull request prepares and which gets its tag at the merge.
|
|
24
|
+
What needs an entry: templates/** of the once-only files (AGENTS.md, .mcp.json, ...), the case document format, the
|
|
25
|
+
rule ids, the peer dependency range. What does not: everything init --update or init's merge rewrites by itself.`;
|
|
26
|
+
/** Files whose change alters what an existing workspace must do, beyond what `init` refreshes by itself. */
|
|
27
|
+
const FORMAT_FILES = [
|
|
28
|
+
{
|
|
29
|
+
file: 'AGENTS.md',
|
|
30
|
+
names: ['AGENTS.md'],
|
|
31
|
+
why: 'the baseline rules and the fixed tokens live there; a workspace copies parts of them',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
file: 'docs/agents/case-format.md',
|
|
35
|
+
names: ['cases/**'],
|
|
36
|
+
why: 'the case document contract changed',
|
|
37
|
+
},
|
|
38
|
+
{ file: 'lib/case-doc.ts', names: ['cases/**'], why: 'the case document parser and its tokens changed' },
|
|
39
|
+
{ file: 'lib/targets.ts', names: ['cases/**'], why: 'target cards changed' },
|
|
40
|
+
{ file: 'lib/values.ts', names: ['cases/**'], why: 'the Values block changed' },
|
|
41
|
+
{ file: 'lib/records.ts', names: ['cases/**'], why: 'the test-data name format changed' },
|
|
42
|
+
{ file: 'lib/rules.ts', names: ['AGENTS.md'], why: 'how project rules are read or validated changed' },
|
|
43
|
+
];
|
|
44
|
+
function git(args) {
|
|
45
|
+
return execFileSync('git', args, { cwd: FRAMEWORK_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
46
|
+
}
|
|
47
|
+
function tryGit(args) {
|
|
48
|
+
try {
|
|
49
|
+
return git(args);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The newest release tag, so the check compares against what users have rather than against the last commit. */
|
|
56
|
+
function newestTag() {
|
|
57
|
+
const tags = tryGit(['for-each-ref', '--sort=-v:refname', '--format=%(refname:short)', 'refs/tags']);
|
|
58
|
+
if (tags === null)
|
|
59
|
+
return null;
|
|
60
|
+
return tags.split('\n').map((tag) => tag.trim()).find((tag) => /^asrun-v\d/.test(tag) || /^v\d/.test(tag)) ?? null;
|
|
61
|
+
}
|
|
62
|
+
/** Changed and untracked paths, relative to the repository root. */
|
|
63
|
+
function changedFiles(base) {
|
|
64
|
+
const diff = tryGit(['diff', '--name-only', base, '--']);
|
|
65
|
+
const untracked = tryGit(['ls-files', '--others', '--exclude-standard']);
|
|
66
|
+
const files = new Set();
|
|
67
|
+
for (const list of [diff, untracked]) {
|
|
68
|
+
for (const line of (list ?? '').split('\n')) {
|
|
69
|
+
const file = line.trim();
|
|
70
|
+
if (file !== '')
|
|
71
|
+
files.add(file.split('\\').join('/'));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...files].sort();
|
|
75
|
+
}
|
|
76
|
+
/** True when the change to `package.json` touches the peer dependency range, which a workspace has to follow. */
|
|
77
|
+
function peerDependencyChanged(base) {
|
|
78
|
+
const file = path.join(FRAMEWORK_ROOT, 'package.json');
|
|
79
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : tryGit(['show', ':package.json']);
|
|
80
|
+
const previous = tryGit(['show', `${base}:package.json`]);
|
|
81
|
+
if (current === null || previous === null)
|
|
82
|
+
return false;
|
|
83
|
+
const peers = (text) => {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.stringify(JSON.parse(text).peerDependencies ?? null);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return peers(current) !== peers(previous);
|
|
92
|
+
}
|
|
93
|
+
/** The surfaces a change list touches. */
|
|
94
|
+
function surfaces(files, base) {
|
|
95
|
+
const found = [];
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const once = ONCE.find((entry) => `templates/${entry.template}` === file);
|
|
98
|
+
if (once !== undefined) {
|
|
99
|
+
// `package.json` is merged by init (scripts and devDependencies) and never replaced, so a change there only needs
|
|
100
|
+
// an entry when the peer dependency range moves, which the workspace has to follow by hand.
|
|
101
|
+
if (once.file === 'package.json')
|
|
102
|
+
continue;
|
|
103
|
+
found.push({ file, names: [once.file], why: `templates/${once.file} is a project-owned file that init never rewrites` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const generated = GENERATED.find((entry) => `templates/${entry.template}` === file);
|
|
107
|
+
if (generated !== undefined)
|
|
108
|
+
continue; // init --update rewrites it; no entry needed
|
|
109
|
+
const format = FORMAT_FILES.find((entry) => entry.file === file);
|
|
110
|
+
if (format !== undefined)
|
|
111
|
+
found.push(format);
|
|
112
|
+
}
|
|
113
|
+
if (files.includes('package.json') && peerDependencyChanged(base)) {
|
|
114
|
+
found.push({ file: 'package.json', names: ['package.json'], why: 'the peer dependency range changed and a workspace has to follow it' });
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
/** True when an entry says it is about one of the names a surface requires. */
|
|
119
|
+
function covers(entry, surface) {
|
|
120
|
+
const files = entry.files ?? [];
|
|
121
|
+
return surface.names.some((name) => files.some((file) => {
|
|
122
|
+
const normalized = file.split('\\').join('/').replace(/^\.\//, '');
|
|
123
|
+
if (normalized === name)
|
|
124
|
+
return true;
|
|
125
|
+
// A `cases/**` name covers a case-format change; a specific case file does not.
|
|
126
|
+
return name.endsWith('/**') && normalized.startsWith(name.slice(0, -2));
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
/** Why an entry is invalid, in the words of the person who wrote it. */
|
|
130
|
+
function entryProblems(entry, pending) {
|
|
131
|
+
const problems = [];
|
|
132
|
+
if (!/^([0-9]+\.[0-9]+\.[0-9]+|unreleased)-[a-z0-9][a-z0-9-]*$/.test(entry.id)) {
|
|
133
|
+
problems.push(`id "${entry.id}" is not <version>-<slug> or unreleased-<slug>`);
|
|
134
|
+
}
|
|
135
|
+
else if (!entry.id.startsWith(`${entry.version}-`)) {
|
|
136
|
+
// The id is what a person sees in `doctor` and in the upgrade output, so it has to name the version the entry
|
|
137
|
+
// declares; freezing writes both together, and a hand edit that moves only one of them leaves a lie behind.
|
|
138
|
+
problems.push(`id "${entry.id}" does not start with "${entry.version}-", the version the entry declares`);
|
|
139
|
+
}
|
|
140
|
+
if (entry.version !== UNRELEASED && !isVersion(entry.version)) {
|
|
141
|
+
problems.push(`version "${entry.version}" is not an x.y.z version (use "${UNRELEASED}" while the release is open)`);
|
|
142
|
+
}
|
|
143
|
+
if ((entry.manual ?? []).length === 0 && entry.auto !== true && entry.advisory !== true) {
|
|
144
|
+
problems.push('it has neither "manual" steps, "auto: true" nor "advisory: true", so nothing would ever clear it');
|
|
145
|
+
}
|
|
146
|
+
if (entry.version !== UNRELEASED && isVersion(entry.version)) {
|
|
147
|
+
// A migration runs only between the release a workspace came from and the release that is installed, so an entry
|
|
148
|
+
// above the installed release is silently skipped. The version in package.json is the ceiling: on a release pull
|
|
149
|
+
// request it is the release being prepared (frozen entries, no tag until the merge) and everywhere else it is the
|
|
150
|
+
// installed release itself.
|
|
151
|
+
const isPending = pending !== null && compareVersions(entry.version, pending) === 0;
|
|
152
|
+
if (pending !== null && compareVersions(entry.version, pending) > 0) {
|
|
153
|
+
problems.push(`version ${entry.version} is newer than ${pending} in package.json, the release this checkout prepares: ` +
|
|
154
|
+
`a migration above the installed release never runs. Use "${UNRELEASED}".`);
|
|
155
|
+
}
|
|
156
|
+
else if (!isPending && !releaseTagExists(entry.version)) {
|
|
157
|
+
const prepared = pending === null ? 'package.json names no readable version' : `package.json prepares ${pending}`;
|
|
158
|
+
problems.push(`version ${entry.version} has no release tag, and it is not the release this checkout prepares (${prepared}): ` +
|
|
159
|
+
`an entry names the release that ships it, so a version that names neither is a mistake. Use "${UNRELEASED}" ` +
|
|
160
|
+
'and let the release pull request name it.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const file of entry.files ?? []) {
|
|
164
|
+
const normalized = file.split('\\').join('/').replace(/^\.\//, '');
|
|
165
|
+
const known = ONCE.some((candidate) => candidate.file === normalized) || normalized.startsWith('cases/') || normalized === 'package.json';
|
|
166
|
+
if (!known)
|
|
167
|
+
problems.push(`files names "${file}", which is not a project-owned file this repository knows`);
|
|
168
|
+
}
|
|
169
|
+
return problems;
|
|
170
|
+
}
|
|
171
|
+
function releaseTagExists(version) {
|
|
172
|
+
const tags = tryGit(['tag', '--list']);
|
|
173
|
+
if (tags === null)
|
|
174
|
+
return true; // no git here: do not block on an environment that cannot answer
|
|
175
|
+
return tags.split('\n').some((tag) => tag.trim() === `asrun-v${version}` || tag.trim() === `v${version}`);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The version package.json declares. On a release pull request release-please has already bumped it and
|
|
179
|
+
* `release-freeze.yml` has frozen the entries to it, while the tag only appears when that pull request is merged:
|
|
180
|
+
* this is the one version the check accepts without a tag, and the ceiling every entry has to stay under.
|
|
181
|
+
*/
|
|
182
|
+
function pendingVersion() {
|
|
183
|
+
try {
|
|
184
|
+
const file = path.join(FRAMEWORK_ROOT, 'package.json');
|
|
185
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
186
|
+
return typeof manifest.version === 'string' && isVersion(manifest.version) ? manifest.version : null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function main(args) {
|
|
193
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
194
|
+
console.log(USAGE);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
const value = (name) => {
|
|
198
|
+
const index = args.indexOf(name);
|
|
199
|
+
return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
|
|
200
|
+
};
|
|
201
|
+
const base = value('--base') ?? newestTag();
|
|
202
|
+
if (base === null) {
|
|
203
|
+
console.error('No release tag to compare with: pass --base <ref> (for example the first commit).');
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
const files = changedFiles(base);
|
|
207
|
+
const found = surfaces(files, base);
|
|
208
|
+
const pending = pendingVersion();
|
|
209
|
+
const problems = [];
|
|
210
|
+
const ids = new Set();
|
|
211
|
+
for (const entry of MIGRATIONS) {
|
|
212
|
+
if (ids.has(entry.id))
|
|
213
|
+
problems.push(`${entry.id}: two migrations share this id`);
|
|
214
|
+
ids.add(entry.id);
|
|
215
|
+
for (const problem of entryProblems(entry, pending))
|
|
216
|
+
problems.push(`${entry.id}: ${problem}`);
|
|
217
|
+
}
|
|
218
|
+
const missing = found.filter((surface) => !MIGRATIONS.some((entry) => covers(entry, surface)));
|
|
219
|
+
if (args.includes('--json')) {
|
|
220
|
+
console.log(JSON.stringify({ base, pending, files, surfaces: found, missing, problems }, null, 2));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(`migrations:check base ${base}`);
|
|
224
|
+
if (pending !== null && !releaseTagExists(pending)) {
|
|
225
|
+
console.log(` ${pending} is the release this checkout prepares (package.json): its tag comes with the merge`);
|
|
226
|
+
}
|
|
227
|
+
const generated = files.filter((file) => GENERATED.some((entry) => `templates/${entry.template}` === file));
|
|
228
|
+
if (found.length === 0)
|
|
229
|
+
console.log(' no surface changed that needs a migration entry');
|
|
230
|
+
for (const surface of found) {
|
|
231
|
+
const covered = !missing.includes(surface);
|
|
232
|
+
console.log(` ${covered ? 'ok ' : '-> '}${surface.file.padEnd(34)} ${covered ? 'covered' : 'needs a migration entry'}`);
|
|
233
|
+
console.log(` ${surface.why}`);
|
|
234
|
+
}
|
|
235
|
+
for (const file of generated)
|
|
236
|
+
console.log(` ${file.padEnd(34)} generated: init --update refreshes it, no entry`);
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
console.log(`\n${missing.length} surface(s) have no migration entry. Add one to migrations/index.ts:`);
|
|
239
|
+
console.log(entryTemplate(missing));
|
|
240
|
+
}
|
|
241
|
+
for (const problem of problems)
|
|
242
|
+
console.log(`\n ! ${problem}`);
|
|
243
|
+
}
|
|
244
|
+
if (missing.length > 0 || problems.length > 0)
|
|
245
|
+
return 1;
|
|
246
|
+
if (!args.includes('--json'))
|
|
247
|
+
console.log('\nEvery changed surface is covered, and every entry is valid.');
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
/** A ready-to-paste entry for the missing surfaces, so the check is faster to satisfy than to ignore. */
|
|
251
|
+
function entryTemplate(missing) {
|
|
252
|
+
const files = [...new Set(missing.flatMap((surface) => surface.names))];
|
|
253
|
+
return [
|
|
254
|
+
'',
|
|
255
|
+
' {',
|
|
256
|
+
` id: '${UNRELEASED}-<slug>',`,
|
|
257
|
+
` version: '${UNRELEASED}',`,
|
|
258
|
+
" summary: '<one line for the person>',",
|
|
259
|
+
" appliesTo: '<which workspaces this matters for>',",
|
|
260
|
+
" details: '<what changed and why>',",
|
|
261
|
+
` files: [${files.map((file) => `'${file}'`).join(', ')}],`,
|
|
262
|
+
' manual: [',
|
|
263
|
+
" '<what a person or an AI has to do, one step per line>',",
|
|
264
|
+
' ],',
|
|
265
|
+
' },',
|
|
266
|
+
'',
|
|
267
|
+
`No version: release-please names it in the release pull request, and "pnpm migrations:freeze" writes it then.`,
|
|
268
|
+
].join('\n');
|
|
269
|
+
}
|
|
270
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
271
|
+
process.exit(main(process.argv.slice(2)));
|
|
272
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): number;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { FRAMEWORK_ROOT } from '../lib/paths.js';
|
|
5
|
+
import { UNRELEASED, isVersion } from '../lib/migrations.js';
|
|
6
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* `pnpm migrations:freeze`: writes the release's version into the entries that are still `unreleased`.
|
|
9
|
+
*
|
|
10
|
+
* Release Please names the version only once the release pull request exists, so an author never guesses one. Entries
|
|
11
|
+
* carry `version: 'unreleased'` until then; `scripts/build.ts` and `pnpm asrun migrate` resolve it to the installed
|
|
12
|
+
* release, so a release is always correct even if nobody freezes. Freezing only fixes the *id* that a workspace will
|
|
13
|
+
* see in `doctor` and in the upgrade output (`unreleased-agents-scaffold` → `0.15.0-agents-scaffold`), which is why the
|
|
14
|
+
* release pull request runs it.
|
|
15
|
+
*
|
|
16
|
+
* The version comes from `package.json`, which the release pull request has already bumped beyond the newest tag.
|
|
17
|
+
*/
|
|
18
|
+
const USAGE = `Usage: pnpm migrations:freeze [--version <x.y.z>] [--check] [--dry-run]
|
|
19
|
+
|
|
20
|
+
--version <v> the release to freeze to (default: package.json, which must be newer than the newest tag)
|
|
21
|
+
--check exit 1 when an entry is still "unreleased"; change nothing (used before publishing)
|
|
22
|
+
--dry-run show what would change, and change nothing`;
|
|
23
|
+
const REGISTRY = path.join(FRAMEWORK_ROOT, 'migrations', 'index.ts');
|
|
24
|
+
function git(args) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync('git', args, { cwd: FRAMEWORK_ROOT, encoding: 'utf8' }).trim();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The versions of the release tags, newest first; used to tell "the release is open" from "not yet". */
|
|
33
|
+
function tags() {
|
|
34
|
+
const list = git(['for-each-ref', '--sort=-v:refname', '--format=%(refname:short)', 'refs/tags']);
|
|
35
|
+
if (list === null)
|
|
36
|
+
return [];
|
|
37
|
+
return list
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((tag) => tag.trim())
|
|
40
|
+
.filter((tag) => /^(asrun-)?v\d/.test(tag))
|
|
41
|
+
.map((tag) => tag.replace(/^asrun-v/, '').replace(/^v/, ''));
|
|
42
|
+
}
|
|
43
|
+
function currentVersion() {
|
|
44
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
|
|
45
|
+
return manifest.version ?? '0.0.0';
|
|
46
|
+
}
|
|
47
|
+
/** The entries that still carry the sentinel, as ids and line numbers, for `--check` and the summary. */
|
|
48
|
+
function pendingEntries() {
|
|
49
|
+
return MIGRATIONS.filter((entry) => entry.version === UNRELEASED || entry.id.startsWith(`${UNRELEASED}-`)).map((entry) => entry.id);
|
|
50
|
+
}
|
|
51
|
+
/** Replaces the sentinel with the release version: `version: 'unreleased'` and the `unreleased-` id prefix. */
|
|
52
|
+
function freezeText(text, version) {
|
|
53
|
+
let replacements = 0;
|
|
54
|
+
const next = text
|
|
55
|
+
.replace(/(\bversion:\s*)(['"`])unreleased\2/g, (_match, head, quote) => {
|
|
56
|
+
replacements++;
|
|
57
|
+
return `${head}${quote}${version}${quote}`;
|
|
58
|
+
})
|
|
59
|
+
.replace(/(\bid:\s*)(['"`])unreleased-/g, (_match, head, quote) => {
|
|
60
|
+
replacements++;
|
|
61
|
+
return `${head}${quote}${version}-`;
|
|
62
|
+
});
|
|
63
|
+
return { next, replacements };
|
|
64
|
+
}
|
|
65
|
+
export function main(args) {
|
|
66
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
67
|
+
console.log(USAGE);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const value = (name) => {
|
|
71
|
+
const index = args.indexOf(name);
|
|
72
|
+
return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
|
|
73
|
+
};
|
|
74
|
+
const check = args.includes('--check');
|
|
75
|
+
const dryRun = args.includes('--dry-run');
|
|
76
|
+
const pending = pendingEntries();
|
|
77
|
+
if (check) {
|
|
78
|
+
if (pending.length === 0) {
|
|
79
|
+
console.log('Every migration entry names a release.');
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
console.error(`${pending.length} migration entr${pending.length === 1 ? 'y is' : 'ies are'} still "unreleased":`);
|
|
83
|
+
for (const id of pending)
|
|
84
|
+
console.error(` ${id}`);
|
|
85
|
+
console.error('\nRun "pnpm migrations:freeze" on the release branch and push it before the release is cut.');
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
if (pending.length === 0) {
|
|
89
|
+
console.log('Nothing to freeze: every migration entry already names a release.');
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
const installed = currentVersion();
|
|
93
|
+
const version = value('--version') ?? installed;
|
|
94
|
+
if (!isVersion(version)) {
|
|
95
|
+
console.error(`"${version}" is not a version. Pass --version x.y.z.`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const released = tags();
|
|
99
|
+
if (value('--version') === undefined && released.includes(version)) {
|
|
100
|
+
console.error(`package.json is still ${version}, which is already a release tag: the release pull request has not bumped it yet.\n` +
|
|
101
|
+
'Wait for the release pull request, then run this on that branch (or pass --version explicitly).');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
const text = fs.readFileSync(REGISTRY, 'utf8');
|
|
105
|
+
const { next, replacements } = freezeText(text, version);
|
|
106
|
+
if (replacements === 0) {
|
|
107
|
+
console.log(`No sentinel text found in ${path.relative(FRAMEWORK_ROOT, REGISTRY)}; nothing to do.`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
console.log(`Would freeze ${pending.length} entr${pending.length === 1 ? 'y' : 'ies'} to ${version} (${replacements} replacements).`);
|
|
112
|
+
for (const id of pending)
|
|
113
|
+
console.log(` ${id} -> ${id.replace(new RegExp(`^${UNRELEASED}-`), `${version}-`)}`);
|
|
114
|
+
console.log('Nothing was written (--dry-run).');
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
fs.writeFileSync(REGISTRY, next);
|
|
118
|
+
console.log(`Froze ${pending.length} migration entr${pending.length === 1 ? 'y' : 'ies'} to ${version}:`);
|
|
119
|
+
for (const id of pending)
|
|
120
|
+
console.log(` ${id} -> ${id.replace(new RegExp(`^${UNRELEASED}-`), `${version}-`)}`);
|
|
121
|
+
console.log('\nNext: "pnpm build" (the manifest in dist/), then "pnpm migrations:check".');
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
125
|
+
process.exit(main(process.argv.slice(2)));
|
|
126
|
+
}
|
package/dist/scripts/upgrade.js
CHANGED
|
@@ -7,6 +7,8 @@ import { compareVersions, isVersion, migrationState, pendingMigrations, readMark
|
|
|
7
7
|
import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
|
|
8
8
|
import { releaseInfo, releaseManifest } from '../lib/registry.js';
|
|
9
9
|
import { excludeReleaseAge } from '../lib/runtime-deps.js';
|
|
10
|
+
import { nextStepsText, upgradePrompt } from '../lib/upgrade-prompt.js';
|
|
11
|
+
import { useColor } from '../lib/term.js';
|
|
10
12
|
import { frameworkPath, PACKAGE } from './init.js';
|
|
11
13
|
/**
|
|
12
14
|
* `pnpm asrun upgrade`: one command that installs the newest framework, refreshes the workspace files and applies the
|
|
@@ -16,12 +18,14 @@ import { frameworkPath, PACKAGE } from './init.js';
|
|
|
16
18
|
* changes are known before anything is written; the install comes next, and `init --update`, `migrate` and `doctor`
|
|
17
19
|
* then run through the *newly installed* package, never through the code that is being replaced.
|
|
18
20
|
*/
|
|
19
|
-
const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes]
|
|
21
|
+
const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes|-y] [--prompt [--json]]
|
|
20
22
|
|
|
21
23
|
--version <v> the release to install (default: latest); a dist-tag or a concrete version
|
|
22
24
|
--root <dir> the workspace to upgrade (default: the current workspace)
|
|
23
25
|
--dry-run print the target release and the migrations it brings, and change nothing
|
|
24
|
-
--yes
|
|
26
|
+
--yes, -y install without asking (a breaking change, or a release that cannot be checked)
|
|
27
|
+
--prompt print the prompt that hands the leftover work to an AI agent, and change nothing (local only)
|
|
28
|
+
--json with --prompt: the same answer as JSON, for the web UI
|
|
25
29
|
|
|
26
30
|
What it does, in this order:
|
|
27
31
|
1. resolve the target release and read its migration manifest from the registry;
|
|
@@ -30,7 +34,11 @@ What it does, in this order:
|
|
|
30
34
|
4. run "init --update", "migrate" and "doctor" through the installed package.
|
|
31
35
|
|
|
32
36
|
A release older than the migration manifest cannot be checked in advance: the command says so and asks before it
|
|
33
|
-
installs. "pnpm asrun migrate" runs only the migration part, for a workspace that was upgraded by hand
|
|
37
|
+
installs. "pnpm asrun migrate" runs only the migration part, for a workspace that was upgraded by hand.
|
|
38
|
+
|
|
39
|
+
When the release changes files the project owns (AGENTS.md and the other once-only files), the upgrade ends with a
|
|
40
|
+
NEXT block. "pnpm asrun upgrade --prompt" prints the task for an AI agent (or run "/upgrade-workspace" in Claude Code or
|
|
41
|
+
OpenCode).`;
|
|
34
42
|
/** How a workspace reaches its install: the nearest `node_modules/@asrunjs/asrun` at or above the workspace root. */
|
|
35
43
|
function installedDir(root) {
|
|
36
44
|
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
@@ -53,20 +61,27 @@ function installedVersion(dir) {
|
|
|
53
61
|
return null;
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* A person answers y/N; an empty line, or anything that is not "y"/"yes", means no. Without a terminal there is nobody
|
|
66
|
+
* to ask, and the command refuses instead of assuming an answer (`--yes` is the way through).
|
|
67
|
+
*/
|
|
57
68
|
async function confirm(question) {
|
|
58
69
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
59
70
|
return null;
|
|
60
71
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
72
|
try {
|
|
62
|
-
|
|
73
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
74
|
+
return answer === 'y' || answer === 'yes';
|
|
63
75
|
}
|
|
64
76
|
finally {
|
|
65
77
|
rl.close();
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
function printMigration(entry) {
|
|
69
|
-
const tags = [
|
|
81
|
+
const tags = [
|
|
82
|
+
entry.breaking === true ? 'breaking' : '',
|
|
83
|
+
entry.advisory === true ? 'nothing to do' : entry.auto === true ? 'runs automatically' : 'needs a person',
|
|
84
|
+
]
|
|
70
85
|
.filter(Boolean)
|
|
71
86
|
.join(', ');
|
|
72
87
|
console.log(` ${entry.breaking === true ? '!' : ' '} ${entry.version} ${entry.summary}`);
|
|
@@ -89,7 +104,9 @@ export async function main(args) {
|
|
|
89
104
|
};
|
|
90
105
|
const root = path.resolve(value('--root') ?? ROOT);
|
|
91
106
|
const dryRun = args.includes('--dry-run');
|
|
92
|
-
const yes = args.includes('--yes');
|
|
107
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
108
|
+
const promptOnly = args.includes('--prompt');
|
|
109
|
+
const asJson = args.includes('--json');
|
|
93
110
|
const spec = value('--version') ?? 'latest';
|
|
94
111
|
if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
|
|
95
112
|
console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun upgrade" in the project that uses ' +
|
|
@@ -100,6 +117,17 @@ export async function main(args) {
|
|
|
100
117
|
console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
|
|
101
118
|
return 1;
|
|
102
119
|
}
|
|
120
|
+
// `--prompt` answers "what is left" for the release that is already installed: it reads the workspace and the
|
|
121
|
+
// installed package only, so it works offline and changes nothing (not even the update cache).
|
|
122
|
+
if (promptOnly) {
|
|
123
|
+
const payload = upgradePrompt(root);
|
|
124
|
+
console.log(asJson ? JSON.stringify(payload, null, 2) : payload.prompt);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
if (asJson) {
|
|
128
|
+
console.error('--json belongs to --prompt: "pnpm asrun upgrade --prompt --json".');
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
103
131
|
console.log('[1/4] Checking the release');
|
|
104
132
|
const directory = installedDir(root);
|
|
105
133
|
const current = installedVersion(directory);
|
|
@@ -177,12 +205,17 @@ export async function main(args) {
|
|
|
177
205
|
return 0;
|
|
178
206
|
}
|
|
179
207
|
if (needsConfirmation) {
|
|
208
|
+
if (breaking.length > 0) {
|
|
209
|
+
console.log('');
|
|
210
|
+
for (const entry of breaking)
|
|
211
|
+
console.log(` ! ${entry.id} ${entry.summary}`);
|
|
212
|
+
}
|
|
180
213
|
const question = breaking.length > 0
|
|
181
|
-
? `\
|
|
182
|
-
: `\
|
|
214
|
+
? `\nInstall ${PACKAGE}@${target} in ${root}, despite ${breaking.length} breaking change(s)?`
|
|
215
|
+
: `\nInstall ${PACKAGE}@${target} in ${root} without the pre-install check?`;
|
|
183
216
|
const answer = await confirm(question);
|
|
184
217
|
if (answer === null) {
|
|
185
|
-
console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes to do it.');
|
|
218
|
+
console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes (or -y) to do it.');
|
|
186
219
|
return 1;
|
|
187
220
|
}
|
|
188
221
|
if (!answer) {
|
|
@@ -190,6 +223,9 @@ export async function main(args) {
|
|
|
190
223
|
return 1;
|
|
191
224
|
}
|
|
192
225
|
}
|
|
226
|
+
else if (yes) {
|
|
227
|
+
console.log(`\nInstalling without asking (--yes).`);
|
|
228
|
+
}
|
|
193
229
|
console.log(`\n[2/4] Installing ${PACKAGE}@${target}`);
|
|
194
230
|
const age = excludeReleaseAge(root, PACKAGE);
|
|
195
231
|
if (age === 'added' || age === 'created') {
|
|
@@ -252,5 +288,12 @@ export async function main(args) {
|
|
|
252
288
|
console.log(`\n[4/4] Checking the workspace`);
|
|
253
289
|
const doctor = run(['doctor']);
|
|
254
290
|
console.log(`\nUpgraded ${PACKAGE} ${current} -> ${after ?? target} in ${root}.`);
|
|
291
|
+
// The release is installed; what is left are files the project owns and the manual steps a migration recorded. The
|
|
292
|
+
// block is printed only when there is something to do, so a patch release stays quiet.
|
|
293
|
+
const next = nextStepsText(root, { color: useColor(process.stdout) });
|
|
294
|
+
if (next === null)
|
|
295
|
+
console.log('Nothing in the project\'s own files needs updating.');
|
|
296
|
+
else
|
|
297
|
+
console.log(`\n${next}`);
|
|
255
298
|
return doctor.status ?? 0;
|
|
256
299
|
}
|
package/dist/ui/server/app.js
CHANGED
|
@@ -15,6 +15,7 @@ import { JobConflictError, jobs } from './jobs.js';
|
|
|
15
15
|
import { listAiModels } from './ai-models.js';
|
|
16
16
|
import { fileEvents } from './watch-files.js';
|
|
17
17
|
import { refreshUpdate, updateEvents, updateInfo } from './update.js';
|
|
18
|
+
import { upgradePrompt } from '../../lib/upgrade-prompt.js';
|
|
18
19
|
const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
19
20
|
// Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
|
|
20
21
|
const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
|
|
@@ -240,6 +241,9 @@ export function createApp({ port }) {
|
|
|
240
241
|
void refreshUpdate(true);
|
|
241
242
|
return c.json(info);
|
|
242
243
|
});
|
|
244
|
+
// What an upgrade leaves open, and the prompt that hands it to an agent. Read-only; the dialog asks for it only when
|
|
245
|
+
// it opens, because it reads the installed templates and can be long.
|
|
246
|
+
api.get('/upgrade/prompt', (c) => c.json(upgradePrompt(ROOT)));
|
|
243
247
|
api.get('/reports', (c) => {
|
|
244
248
|
if (!fs.existsSync(REPORTS_DIR))
|
|
245
249
|
return c.json([]);
|
|
@@ -348,6 +348,12 @@ export interface ReportInfo {
|
|
|
348
348
|
counts: Record<ResultStatus, number> | null;
|
|
349
349
|
}
|
|
350
350
|
export type { ParsedError, ResultStatus, RunSettings, RunSummary, TestRunResult } from '../../lib/run-summary.js';
|
|
351
|
+
/**
|
|
352
|
+
* What an upgrade leaves to a person or an AI, and the prompt that hands it over. Built by `lib/upgrade-prompt.ts`,
|
|
353
|
+
* the same module `pnpm asrun upgrade --prompt --json` prints.
|
|
354
|
+
*/
|
|
355
|
+
export type { ScaffoldDrift, ScaffoldState } from '../../lib/scaffold.js';
|
|
356
|
+
export type { OutstandingFile, OutstandingMigration, UpgradeOutstanding, UpgradePromptPayload } from '../../lib/upgrade-prompt.js';
|
|
351
357
|
export interface InfoResponse {
|
|
352
358
|
root: string;
|
|
353
359
|
envs: string[];
|
|
@@ -149,6 +149,8 @@ One card per distinct thing a check looks at:
|
|
|
149
149
|
- `What`, `Where` and `Identified by` are required for a card that is not blocking.
|
|
150
150
|
- `Reference <profile-id>:` must point under `.explore/targets/<case-id>/` (git-ignored), or be `none: <reason>`.
|
|
151
151
|
A missing file on this machine is a warning, not a failure: a fresh checkout has the card but not the screenshot.
|
|
152
|
+
A card carries one line per profile that was looked at separately: an `--lead` exploration adds its own line for a
|
|
153
|
+
profile whose element differs, and `none: <reason>` for one that shares the lead's screenshot.
|
|
152
154
|
- A card may hold a `🚫 TODO [blocking]` block. Such a card is not complete; `pnpm case:check` does not fail on it,
|
|
153
155
|
and the check that uses it is not generated yet.
|
|
154
156
|
- Every `[T<n>]` in a step, a ready signal or a check resolves to a card. `pnpm case:check` fails on a reference with
|
|
@@ -208,6 +210,10 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
|
|
|
208
210
|
by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
|
|
209
211
|
it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
|
|
210
212
|
behind `emailStepAllowed(profile, '<step title>')`.
|
|
213
|
+
- `/explore-case --lead` explores one profile in full and the others only where they can differ. What a diff profile
|
|
214
|
+
adds to the case is its `## Values` entry and its `Reference <profile-id>:` line, never a second copy of the steps;
|
|
215
|
+
`/gen-spec` writes those profiles' parts from the lead's note and names them `not verified`. A step whose recipe
|
|
216
|
+
would differ per profile has no per-profile form (`- Recipe:` belongs to the step): split the case, or ask.
|
|
211
217
|
|
|
212
218
|
## Checks to run before reporting
|
|
213
219
|
|