@asrunjs/asrun 0.7.0 → 0.9.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 (73) hide show
  1. package/.claude/commands/explore-case.md +6 -2
  2. package/.claude/commands/gen-spec.md +4 -1
  3. package/.claude/commands/heal-spec.md +18 -7
  4. package/.claude/skills/exploring-cases/SKILL.md +19 -8
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/generating-specs/SKILL.md +85 -30
  7. package/AGENTS.md +23 -12
  8. package/README.md +25 -14
  9. package/bin/asrun.mjs +18 -2
  10. package/dist/lib/ffmpeg.js +21 -10
  11. package/dist/lib/migrations.d.ts +106 -0
  12. package/dist/lib/migrations.js +106 -0
  13. package/dist/lib/paths.d.ts +9 -0
  14. package/dist/lib/paths.js +12 -1
  15. package/dist/lib/registry.d.ts +31 -0
  16. package/dist/lib/registry.js +66 -0
  17. package/dist/lib/runtime-deps.d.ts +59 -0
  18. package/dist/lib/runtime-deps.js +161 -0
  19. package/dist/lib/tar.d.ts +5 -0
  20. package/dist/lib/tar.js +79 -0
  21. package/dist/lib/update-check.d.ts +52 -0
  22. package/dist/lib/update-check.js +223 -0
  23. package/dist/migrations/index.d.ts +23 -0
  24. package/dist/migrations/index.js +22 -0
  25. package/dist/migrations/manifest.json +1 -0
  26. package/dist/scripts/build.js +7 -0
  27. package/dist/scripts/cli.js +22 -4
  28. package/dist/scripts/doctor.js +51 -9
  29. package/dist/scripts/go.mjs +7 -5
  30. package/dist/scripts/init.d.ts +3 -2
  31. package/dist/scripts/init.js +73 -11
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/run.js +2 -1
  35. package/dist/scripts/upgrade.d.ts +1 -0
  36. package/dist/scripts/upgrade.js +256 -0
  37. package/dist/ui/server/actions.js +9 -4
  38. package/dist/ui/server/app.js +18 -0
  39. package/dist/ui/server/index.js +3 -0
  40. package/dist/ui/server/update.d.ts +18 -0
  41. package/dist/ui/server/update.js +87 -0
  42. package/dist/ui/shared/ai-commands.d.ts +6 -3
  43. package/dist/ui/shared/ai-commands.js +11 -7
  44. package/dist/ui/shared/types.d.ts +25 -1
  45. package/docs/agents/framework-dev.md +74 -4
  46. package/docs/ai-workflow.md +38 -11
  47. package/docs/commands.md +74 -4
  48. package/docs/install.md +52 -16
  49. package/docs/theme-guide.html +93 -5
  50. package/docs/troubleshooting.md +22 -10
  51. package/docs/web-ui.md +28 -6
  52. package/package.json +2 -4
  53. package/templates/.claude/commands/explore-case.md +7 -2
  54. package/templates/.claude/commands/gen-spec.md +4 -1
  55. package/templates/.claude/commands/heal-spec.md +22 -12
  56. package/templates/AGENTS.md +1 -0
  57. package/templates/pnpm-workspace.yaml +3 -3
  58. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-B7HeglDd.js} +1 -1
  59. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-B9_GXR2G.js} +1 -1
  60. package/ui/dist/assets/{dist-CO4heSzh.js → dist-Bb36rNUw.js} +1 -1
  61. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-DevASegc.js} +1 -1
  62. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DfBpjgZT.js} +1 -1
  63. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-Dq4Vnw5N.js} +1 -1
  64. package/ui/dist/assets/{dist-CPLhRzom.js → dist-Dqc_tfzC.js} +1 -1
  65. package/ui/dist/assets/{dist-LixzGniF.js → dist-G9KNeh6u.js} +1 -1
  66. package/ui/dist/assets/{dist-BudNAjOv.js → dist-GvRUDL-2.js} +1 -1
  67. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-WvvCZeeu.js} +1 -1
  68. package/ui/dist/assets/index-BJ5D2mFK.js +346 -0
  69. package/ui/dist/assets/index-BXY-eKBn.css +2 -0
  70. package/ui/dist/help/case-explore.png +0 -0
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/assets/index-BIGt8Hfz.js +0 -338
  73. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -0,0 +1,79 @@
1
+ import zlib from 'node:zlib';
2
+ /**
3
+ * Reads one file out of a `.tgz`/`.tar`, which is how `pnpm asrun upgrade` reads the migration manifest of the release
4
+ * it is about to install: the registry serves the package as a tarball, and the manifest has to be looked at before
5
+ * anything is installed. No external `tar` binary and no dependency: npm writes plain (portable) ustar entries.
6
+ */
7
+ const BLOCK = 512;
8
+ function text(header, offset, length) {
9
+ const end = header.indexOf(0, offset);
10
+ const stop = end === -1 || end > offset + length ? offset + length : end;
11
+ return header.subarray(offset, stop).toString('utf8');
12
+ }
13
+ /** A numeric header field: octal digits, NUL/space padded. */
14
+ function octal(header, offset, length) {
15
+ const raw = text(header, offset, length).trim();
16
+ return raw === '' ? 0 : parseInt(raw, 8);
17
+ }
18
+ function isZeroBlock(block) {
19
+ for (const byte of block)
20
+ if (byte !== 0)
21
+ return false;
22
+ return true;
23
+ }
24
+ /** The path of a PAX extended header (`LEN key=value\n` records), or null when it carries none. */
25
+ function paxPath(body) {
26
+ let offset = 0;
27
+ while (offset < body.length) {
28
+ const space = body.indexOf(0x20, offset); // the length field ends at the first space
29
+ if (space === -1)
30
+ break;
31
+ const length = Number(body.subarray(offset, space).toString('utf8'));
32
+ if (!Number.isFinite(length) || length <= 0)
33
+ break;
34
+ const record = body.subarray(space + 1, offset + length - 1).toString('utf8'); // without the trailing newline
35
+ const equals = record.indexOf('=');
36
+ if (equals > 0 && record.slice(0, equals).replace(/^\w+\./, '') === 'path')
37
+ return record.slice(equals + 1);
38
+ offset += length;
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * The content of `wanted` inside a tarball (gzipped or not), or null when the archive has no such file. Only regular
44
+ * files are considered; a directory entry of the same name is skipped.
45
+ */
46
+ export function readFileFromTarball(archive, wanted) {
47
+ const data = archive.length > 1 && archive[0] === 0x1f && archive[1] === 0x8b ? zlib.gunzipSync(archive) : archive;
48
+ // A PAX or GNU long-name entry names the file of the *next* header; npm tarballs use one for long paths.
49
+ let pendingName;
50
+ for (let offset = 0; offset + BLOCK <= data.length;) {
51
+ const header = data.subarray(offset, offset + BLOCK);
52
+ if (isZeroBlock(header))
53
+ break;
54
+ const size = octal(header, 124, 12);
55
+ const type = String.fromCharCode(header[156] || 0x30);
56
+ const body = data.subarray(offset + BLOCK, offset + BLOCK + size);
57
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
58
+ if (type === 'x') {
59
+ pendingName = paxPath(body) ?? pendingName;
60
+ continue;
61
+ }
62
+ if (type === 'L') {
63
+ pendingName = text(body, 0, body.length);
64
+ continue;
65
+ }
66
+ // A global header (`g`) and the link-name header (`K`) say nothing about the next file's path.
67
+ if (type === 'g' || type === 'K')
68
+ continue;
69
+ const name = text(header, 0, 100);
70
+ const prefix = text(header, 345, 155);
71
+ const fullName = pendingName ?? (prefix === '' ? name : `${prefix}/${name}`);
72
+ pendingName = undefined;
73
+ if (type !== '0' && type !== '\0' && type !== '7')
74
+ continue;
75
+ if (fullName === wanted)
76
+ return body;
77
+ }
78
+ return null;
79
+ }
@@ -0,0 +1,52 @@
1
+ export declare const CACHE_FILE: string;
2
+ /** Development only: a hand-written answer, so the notice can be seen without publishing a release. */
3
+ export declare const MOCK_FILE: string;
4
+ /** `installed` is a package from the registry; `checkout` is the framework's own git repository. */
5
+ export type InstallKind = 'installed' | 'checkout';
6
+ /** What the last check found. `latest` stays null when the registry could not be read (`error` says why). */
7
+ export interface UpdateStatus {
8
+ /** The version in the framework's own `package.json`. */
9
+ current: string;
10
+ latest: string | null;
11
+ /** True when `latest` is a version newer than `current`. */
12
+ available: boolean;
13
+ /** When the answer was fetched (ISO); the cached answer keeps the time of the fetch. */
14
+ checkedAt: string;
15
+ kind: InstallKind;
16
+ /** True when ASRUN_NO_UPDATE_CHECK turned the check off; nothing was fetched. */
17
+ disabled: boolean;
18
+ /** True when a development mock supplied the version or the install kind instead of the registry. */
19
+ mocked: boolean;
20
+ error: string | null;
21
+ }
22
+ /**
23
+ * The newest release on the registry, compared with what this process runs. `force` asks the registry again even when
24
+ * the cache is fresh (the web UI's daily check does that); every other caller shares the cache.
25
+ *
26
+ * A development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`) answers instead of the registry and is never
27
+ * cached, so removing it brings the real check back at once.
28
+ */
29
+ export declare function checkForUpdate(options?: {
30
+ force?: boolean;
31
+ }): UpdateStatus;
32
+ /** The command that moves this installation to the newest release. */
33
+ export declare function upgradeCommand(kind: InstallKind): string;
34
+ /**
35
+ * Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
36
+ * `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
37
+ */
38
+ export declare function useColor(stream: {
39
+ isTTY?: boolean;
40
+ }): boolean;
41
+ /**
42
+ * The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
43
+ * command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
44
+ */
45
+ export declare function formatNotice(status: UpdateStatus, options: {
46
+ color?: boolean;
47
+ }): string | null;
48
+ /**
49
+ * Prints the notice on stderr, before a command does its own work. stderr keeps stdout clean for pipelines, and any
50
+ * failure here is swallowed: a command's own result never depends on the registry answering.
51
+ */
52
+ export declare function announceUpdate(): void;
@@ -0,0 +1,223 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { compareVersions, isVersion } from './migrations.js';
4
+ import { FRAMEWORK_ROOT, PACKAGE, TMP_DIR, isRepoCheckout } from './paths.js';
5
+ import { releaseInfo } from './registry.js';
6
+ /**
7
+ * Is a newer release of the framework on the registry? The command line asks before every command and the web UI asks
8
+ * once at startup and once a day; both read the same cache in `.tmp/`, so the registry is asked at most once a day per
9
+ * machine. A failed read is silent: a check that cannot run must never get in the way of a real command.
10
+ */
11
+ /** The dist-tag the check compares against. */
12
+ const DIST_TAG = 'latest';
13
+ /** How long a successful answer is reused. */
14
+ const FRESH_MS = 24 * 60 * 60 * 1000;
15
+ /** A failed read is retried much sooner, so an offline minute does not hide the next real answer for a day. */
16
+ const RETRY_MS = 60 * 60 * 1000;
17
+ /** The registry is a side question: it never holds a command up for longer than this. */
18
+ const TIMEOUT_MS = 5_000;
19
+ export const CACHE_FILE = path.join(TMP_DIR, 'update-check.json');
20
+ /** Development only: a hand-written answer, so the notice can be seen without publishing a release. */
21
+ export const MOCK_FILE = path.join(TMP_DIR, 'update-mock.json');
22
+ /** ASRUN_NO_UPDATE_CHECK=1 (or true/yes) turns the check off, for a pipeline or a machine without a registry. */
23
+ function checksDisabled() {
24
+ const value = (process.env.ASRUN_NO_UPDATE_CHECK ?? '').trim().toLowerCase();
25
+ return value !== '' && value !== '0' && value !== 'false' && value !== 'no';
26
+ }
27
+ function mockKind(value) {
28
+ return value === 'installed' || value === 'checkout' ? value : undefined;
29
+ }
30
+ /** The mock the environment names: ASRUN_UPDATE_MOCK_LATEST / ASRUN_UPDATE_MOCK_KIND, both optional. */
31
+ function mockFromEnv() {
32
+ const latest = (process.env.ASRUN_UPDATE_MOCK_LATEST ?? '').trim();
33
+ const kind = mockKind((process.env.ASRUN_UPDATE_MOCK_KIND ?? '').trim().toLowerCase());
34
+ const validLatest = isVersion(latest) ? latest : undefined;
35
+ if (validLatest === undefined && kind === undefined)
36
+ return null;
37
+ return { ...(validLatest !== undefined && { latest: validLatest }), ...(kind !== undefined && { kind }) };
38
+ }
39
+ /** The mock in `.tmp/update-mock.json`, written for a live demo of the notice; malformed fields are ignored. */
40
+ function mockFromFile() {
41
+ try {
42
+ const parsed = JSON.parse(fs.readFileSync(MOCK_FILE, 'utf8'));
43
+ const latest = typeof parsed.latest === 'string' && isVersion(parsed.latest.trim()) ? parsed.latest.trim() : undefined;
44
+ const kind = mockKind(parsed.kind);
45
+ if (latest === undefined && kind === undefined)
46
+ return null;
47
+ return { ...(latest !== undefined && { latest }), ...(kind !== undefined && { kind }) };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** The development mock, field by field: the environment wins over the file, so a shell variable can pin one field. */
54
+ function mockConfig() {
55
+ const env = mockFromEnv();
56
+ const file = mockFromFile();
57
+ if (env === null && file === null)
58
+ return null;
59
+ const latest = env?.latest ?? file?.latest;
60
+ const kind = env?.kind ?? file?.kind;
61
+ return { ...(latest !== undefined && { latest }), ...(kind !== undefined && { kind }) };
62
+ }
63
+ /** The version of the framework this process runs, or null when its manifest cannot be read. */
64
+ function installedVersion() {
65
+ try {
66
+ const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
67
+ return typeof manifest.version === 'string' ? manifest.version : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function readCache() {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
76
+ if (typeof parsed.checkedAt !== 'number' || typeof parsed.current !== 'string')
77
+ return null;
78
+ return {
79
+ checkedAt: parsed.checkedAt,
80
+ current: parsed.current,
81
+ latest: typeof parsed.latest === 'string' ? parsed.latest : null,
82
+ error: typeof parsed.error === 'string' ? parsed.error : null,
83
+ };
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** Written next to the reports and the other local state; a read-only workspace simply keeps no cache. */
90
+ function writeCache(entry) {
91
+ try {
92
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
93
+ fs.writeFileSync(CACHE_FILE, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
94
+ }
95
+ catch {
96
+ // No cache is only a slower check next time.
97
+ }
98
+ }
99
+ function statusFrom(entry, kind, disabled, mocked = false) {
100
+ const available = entry.latest !== null &&
101
+ isVersion(entry.latest) &&
102
+ isVersion(entry.current) &&
103
+ compareVersions(entry.latest, entry.current) > 0;
104
+ return {
105
+ current: entry.current,
106
+ latest: entry.latest,
107
+ available,
108
+ checkedAt: new Date(entry.checkedAt).toISOString(),
109
+ kind,
110
+ disabled,
111
+ mocked,
112
+ error: entry.error,
113
+ };
114
+ }
115
+ /**
116
+ * The newest release on the registry, compared with what this process runs. `force` asks the registry again even when
117
+ * the cache is fresh (the web UI's daily check does that); every other caller shares the cache.
118
+ *
119
+ * A development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`) answers instead of the registry and is never
120
+ * cached, so removing it brings the real check back at once.
121
+ */
122
+ export function checkForUpdate(options = {}) {
123
+ const mock = mockConfig();
124
+ const kind = mock?.kind ?? (isRepoCheckout() ? 'checkout' : 'installed');
125
+ const current = installedVersion();
126
+ if (current === null) {
127
+ return {
128
+ current: 'unknown',
129
+ latest: null,
130
+ available: false,
131
+ checkedAt: new Date().toISOString(),
132
+ kind,
133
+ disabled: checksDisabled(),
134
+ mocked: mock !== null,
135
+ error: `could not read ${path.join(FRAMEWORK_ROOT, 'package.json')}`,
136
+ };
137
+ }
138
+ if (checksDisabled()) {
139
+ return {
140
+ current,
141
+ latest: null,
142
+ available: false,
143
+ checkedAt: new Date().toISOString(),
144
+ kind,
145
+ disabled: true,
146
+ mocked: mock !== null,
147
+ error: null,
148
+ };
149
+ }
150
+ if (mock?.latest !== undefined) {
151
+ return statusFrom({ checkedAt: Date.now(), current, latest: mock.latest, error: null }, kind, false, true);
152
+ }
153
+ const cached = readCache();
154
+ if (options.force !== true && cached !== null && cached.current === current) {
155
+ const age = Date.now() - cached.checkedAt;
156
+ const ttl = cached.latest === null ? RETRY_MS : FRESH_MS;
157
+ if (age >= 0 && age < ttl)
158
+ return statusFrom(cached, kind, false, mock !== null);
159
+ }
160
+ const lookup = releaseInfo(PACKAGE, DIST_TAG, TIMEOUT_MS);
161
+ const entry = 'info' in lookup && isVersion(lookup.info.version)
162
+ ? { checkedAt: Date.now(), current, latest: lookup.info.version, error: null }
163
+ : {
164
+ checkedAt: Date.now(),
165
+ current,
166
+ latest: null,
167
+ error: 'error' in lookup ? lookup.error : `the registry answered "${lookup.info.version}", which is not a version`,
168
+ };
169
+ writeCache(entry);
170
+ return statusFrom(entry, kind, false, mock !== null);
171
+ }
172
+ /** The command that moves this installation to the newest release. */
173
+ export function upgradeCommand(kind) {
174
+ return kind === 'checkout' ? 'git pull' : 'pnpm asrun upgrade';
175
+ }
176
+ /**
177
+ * Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
178
+ * `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
179
+ */
180
+ export function useColor(stream) {
181
+ const force = (process.env.FORCE_COLOR ?? '').trim().toLowerCase();
182
+ if (force !== '' && force !== '0' && force !== 'false')
183
+ return true;
184
+ const noColor = (process.env.NO_COLOR ?? '').trim();
185
+ if (noColor !== '')
186
+ return false;
187
+ return stream.isTTY === true;
188
+ }
189
+ /** One ANSI-styled run, or the text untouched when colors are off. */
190
+ const paint = (text, code, color) => (color ? `\u001b[${code}m${text}\u001b[0m` : text);
191
+ /**
192
+ * The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
193
+ * command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
194
+ */
195
+ export function formatNotice(status, options) {
196
+ if (!status.available || status.latest === null)
197
+ return null;
198
+ const color = options.color === true;
199
+ // The plain line reads as a sentence; the badge only exists to catch the eye on a terminal.
200
+ const badge = color ? `${paint(' UPDATE ', '43;30;1', true)} ` : '';
201
+ const release = paint(`${PACKAGE} ${status.latest}`, '1;33', color);
202
+ const command = paint(upgradeCommand(status.kind), '1;36', color);
203
+ const tail = status.kind === 'checkout'
204
+ ? `is available (this checkout is ${status.current}). Update the framework checkout: ${command}`
205
+ : `is available (installed ${status.current}). Upgrade: ${command}`;
206
+ return `${badge}${release} ${tail}`;
207
+ }
208
+ /**
209
+ * Prints the notice on stderr, before a command does its own work. stderr keeps stdout clean for pipelines, and any
210
+ * failure here is swallowed: a command's own result never depends on the registry answering.
211
+ */
212
+ export function announceUpdate() {
213
+ if (process.argv.includes('--help'))
214
+ return;
215
+ try {
216
+ const notice = formatNotice(checkForUpdate(), { color: useColor(process.stderr) });
217
+ if (notice !== null)
218
+ console.error(notice);
219
+ }
220
+ catch {
221
+ // A check is a courtesy; a broken one is not a command failure.
222
+ }
223
+ }
@@ -0,0 +1,23 @@
1
+ import type { Migration } from '../lib/migrations.js';
2
+ /**
3
+ * The workspace migrations of this framework, in the order they were introduced.
4
+ *
5
+ * A migration is a change an *existing* workspace has to make when the framework moves it from an older release to a
6
+ * newer one: a config entry that moved, a file whose shape changed, a dependency range that has to follow the peer
7
+ * dependency. Anything `init --update` refreshes by itself is not a migration (that is what the generated files are
8
+ * for), and anything that only concerns brand-new workspaces belongs in `templates/`.
9
+ *
10
+ * Adding one:
11
+ * 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
12
+ * (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
13
+ * 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
14
+ * adds what the summary cannot, and `manual` lists what only a person can do.
15
+ * 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
16
+ * workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
17
+ * config) is a `manual` step, never an automatic edit.
18
+ * 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
19
+ * installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
20
+ * 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
21
+ * in `docs/agents/framework-dev.md`.
22
+ */
23
+ export declare const MIGRATIONS: Migration[];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The workspace migrations of this framework, in the order they were introduced.
3
+ *
4
+ * A migration is a change an *existing* workspace has to make when the framework moves it from an older release to a
5
+ * newer one: a config entry that moved, a file whose shape changed, a dependency range that has to follow the peer
6
+ * dependency. Anything `init --update` refreshes by itself is not a migration (that is what the generated files are
7
+ * for), and anything that only concerns brand-new workspaces belongs in `templates/`.
8
+ *
9
+ * Adding one:
10
+ * 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
11
+ * (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
12
+ * 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
13
+ * adds what the summary cannot, and `manual` lists what only a person can do.
14
+ * 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
15
+ * workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
16
+ * config) is a `manual` step, never an automatic edit.
17
+ * 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
18
+ * installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
19
+ * 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
20
+ * in `docs/agents/framework-dev.md`.
21
+ */
22
+ export const MIGRATIONS = [];
@@ -0,0 +1 @@
1
+ []
@@ -2,8 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { COPIED_SCRIPTS } from '../lib/build-artifacts.js';
5
+ import { MANIFEST_FILE, toManifest } from '../lib/migrations.js';
5
6
  import { commandScript } from '../lib/node-cli.js';
6
7
  import { FRAMEWORK_ROOT } from '../lib/paths.js';
8
+ import { MIGRATIONS } from '../migrations/index.js';
7
9
  // Compiles the framework to `dist/`: JavaScript and type declarations that Node and Playwright can load from a
8
10
  // node_modules folder (Node refuses to strip types there). The plain `.mjs` tools are copied as they are.
9
11
  const tsc = commandScript('typescript', 'tsc');
@@ -19,3 +21,8 @@ for (const file of COPIED_SCRIPTS) {
19
21
  fs.mkdirSync(path.dirname(to), { recursive: true });
20
22
  fs.copyFileSync(from, to);
21
23
  }
24
+ // The migration manifest of the published package. A still-old `pnpm asrun upgrade` reads it out of the *target*
25
+ // tarball before installing, so it has to be in `dist/`, next to the compiled registry the installed command uses.
26
+ const manifestFile = path.join(FRAMEWORK_ROOT, MANIFEST_FILE);
27
+ fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
28
+ fs.writeFileSync(manifestFile, JSON.stringify(toManifest(MIGRATIONS), null, 2) + '\n');
@@ -1,6 +1,11 @@
1
1
  // `pnpm asrun <command>`: a thin dispatcher so every subcommand keeps one entry point.
2
2
  // Anything that is not a subcommand is passed to the run command unchanged.
3
+ import { announceUpdate } from '../lib/update-check.js';
3
4
  const [command, ...rest] = process.argv.slice(2);
5
+ // The same courtesy the installed `asrun` command shows: one line on stderr when the registry has a newer release.
6
+ // `upgrade` reads the registry itself, and `help` has no work to do.
7
+ if (!['upgrade', 'help', '--help-init'].includes(command ?? '') && !process.argv.includes('--help'))
8
+ announceUpdate();
4
9
  async function main() {
5
10
  if (command === 'init') {
6
11
  const { main: init } = await import('./init.js');
@@ -17,20 +22,33 @@ async function main() {
17
22
  process.exitCode = await reset(rest);
18
23
  return;
19
24
  }
25
+ if (command === 'upgrade') {
26
+ const { main: upgrade } = await import('./upgrade.js');
27
+ process.exitCode = await upgrade(rest);
28
+ return;
29
+ }
30
+ if (command === 'migrate') {
31
+ const { main: migrate } = await import('./migrate.js');
32
+ process.exitCode = await migrate(rest);
33
+ return;
34
+ }
20
35
  if (command === 'help' || command === '--help-init') {
21
36
  console.log(USAGE);
22
37
  return;
23
38
  }
24
39
  await import('./run.js');
25
40
  }
26
- const USAGE = `Usage: pnpm asrun [init|doctor|reset] [options]
41
+ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
27
42
 
28
43
  (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
29
44
  init Create the files a workspace needs (never overwrites; --update refreshes generated files,
30
45
  --root <dir> targets another folder, --force overwrites every file init writes,
31
46
  --force <path> overwrites one file).
32
- doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides.
47
+ doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides, migrations.
33
48
  reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
34
- profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").`;
49
+ profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
50
+ upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
51
+ target release for breaking changes (see "pnpm asrun upgrade --help").
52
+ migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
53
+ --help"). "upgrade" runs it; use it after a manual install.`;
35
54
  await main();
36
- export {};
@@ -3,7 +3,10 @@ import path from 'node:path';
3
3
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
4
4
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
5
5
  import { spawnTarget } from '../lib/command.js';
6
+ import { migrationState, pendingMigrations } from '../lib/migrations.js';
7
+ import { MIGRATIONS } from '../migrations/index.js';
6
8
  import { GENERATED, frameworkPath, frameworkVersion, PACKAGE, renderTemplate } from './init.js';
9
+ import { BUILD_APPROVALS, RUNTIME_EXTRAS, hasCapability, installedInWorkspace } from '../lib/runtime-deps.js';
7
10
  import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
8
11
  /**
9
12
  * Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
@@ -87,9 +90,11 @@ export async function main(args) {
87
90
  errors.push(`${PACKAGE} is not installed in this workspace: run "pnpm add -D ${PACKAGE}", then "pnpm asrun init". ` +
88
91
  'Every script that calls "asrun" fails until then.');
89
92
  }
90
- // The manifest, written by init: it names where the workspace reaches the framework.
93
+ // The manifest, written by init: it names where the workspace reaches the framework, and since when the workspace
94
+ // has been migrated.
91
95
  const manifest = read(path.join(ROOT, '.asrun.json'));
92
96
  let recordedPath;
97
+ let marker = null;
93
98
  const installedVersion = frameworkVersion();
94
99
  if (manifest === null) {
95
100
  errors.push('No .asrun.json: this workspace was never initialized, or "init" stopped before it finished (for example the ' +
@@ -98,10 +103,12 @@ export async function main(args) {
98
103
  else {
99
104
  try {
100
105
  const parsed = JSON.parse(manifest);
106
+ marker = parsed;
101
107
  recordedPath = parsed.frameworkPath;
102
- facts.push(`version: asrun ${parsed.version ?? '?'} (${parsed.commit ?? '?'})`);
103
- if (parsed.version !== undefined && parsed.version !== installedVersion.version) {
104
- warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: run "pnpm asrun init --update".`);
108
+ facts.push(`version: asrun ${typeof parsed.version === 'string' ? parsed.version : '?'} (${parsed.commit ?? '?'})`);
109
+ if (typeof parsed.version === 'string' && parsed.version !== installedVersion.version) {
110
+ warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: ` +
111
+ 'run "pnpm asrun upgrade" (or reinstall the version this workspace needs).');
105
112
  }
106
113
  if (recordedPath !== undefined && !fs.existsSync(path.resolve(ROOT, recordedPath))) {
107
114
  errors.push(`.asrun.json points at "${recordedPath}", which does not exist. Run "pnpm add -D @asrunjs/asrun" and then "pnpm asrun init".`);
@@ -112,6 +119,27 @@ export async function main(args) {
112
119
  }
113
120
  }
114
121
  const framework = recordedPath ?? actualPath;
122
+ // Workspace migrations: `migrations.since` is the release the workspace matches, so newer ones are still to do. A
123
+ // workspace upgraded by hand (pnpm add + init --update) keeps the old baseline, which is what makes this check work.
124
+ if (marker !== null) {
125
+ try {
126
+ const state = migrationState(marker, installedVersion.version);
127
+ const outstanding = state.manual.length;
128
+ facts.push(`migrations: since ${state.since}${outstanding > 0 ? `, ${outstanding} manual step(s) outstanding` : ''}`);
129
+ const pending = pendingMigrations(MIGRATIONS, state.since, installedVersion.version);
130
+ if (pending.length > 0) {
131
+ warnings.push(`${pending.length} workspace migration(s) apply between ${state.since} and ${installedVersion.version} ` +
132
+ `(${pending.map((migration) => migration.id).join(', ')}): run "pnpm asrun migrate".`);
133
+ }
134
+ if (outstanding > 0) {
135
+ warnings.push(`Manual migration steps are still open (${state.manual.join(', ')}): run "pnpm asrun migrate --list" to see ` +
136
+ 'them, then "pnpm asrun migrate --ack <id>" once they are done.');
137
+ }
138
+ }
139
+ catch (error) {
140
+ errors.push(`The migration registry is invalid: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
115
143
  // The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
116
144
  // load the config from there. A build older than the sources therefore keeps runs on the previous code.
117
145
  const stale = staleDistFiles();
@@ -231,13 +259,27 @@ export async function main(args) {
231
259
  // The framework is outside the workspace (examples/demo): the framework repository manages itself.
232
260
  }
233
261
  else if (workspaceFile === null) {
234
- warnings.push('pnpm-workspace.yaml is missing: add the "allowBuilds" block from templates/pnpm-workspace.yaml, or pnpm skips the build scripts of esbuild and ffmpeg-static.');
262
+ warnings.push(`pnpm-workspace.yaml is missing: run "pnpm asrun init" (it writes the "allowBuilds" entries for ${BUILD_APPROVALS.join(', ')}), or pnpm skips those install scripts.`);
235
263
  }
236
- else if (!workspaceFile.includes('allowBuilds')) {
237
- warnings.push('pnpm-workspace.yaml has no "allowBuilds": pnpm skips the build scripts of esbuild and ffmpeg-static.');
264
+ else {
265
+ const missing = BUILD_APPROVALS.filter((pkg) => {
266
+ const entry = new RegExp(`^\\s+['"]?${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*:`, 'm');
267
+ return !entry.test(workspaceFile);
268
+ });
269
+ if (missing.length > 0) {
270
+ warnings.push(`pnpm-workspace.yaml has no "allowBuilds" entry for ${missing.join(', ')}: run "pnpm asrun init" (it adds ` +
271
+ 'the entry before installing), or pnpm skips that install script.');
272
+ }
273
+ if (/^packages\s*:/m.test(workspaceFile)) {
274
+ warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
275
+ }
238
276
  }
239
- else if (/^packages\s*:/m.test(workspaceFile)) {
240
- warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
277
+ // Optional packages the workspace installs on demand, when this machine does not provide the capability itself.
278
+ for (const extra of RUNTIME_EXTRAS) {
279
+ if (hasCapability(extra) || installedInWorkspace(ROOT, extra.pkg) || installedInWorkspace(FRAMEWORK_ROOT, extra.pkg))
280
+ continue;
281
+ warnings.push(`${extra.pkg} is not installed and this machine does not provide it: ${extra.provides} is skipped. Run ` +
282
+ '"pnpm asrun init" (or "pnpm go") to install it, or install the tool yourself and run doctor again.');
241
283
  }
242
284
  let scripts = null;
243
285
  try {
@@ -111,9 +111,10 @@ if (dryRun) {
111
111
  function ensurePnpmWorkspace() {
112
112
  if (frameworkRelative.startsWith('..')) return 'skipped';
113
113
  const file = path.join(ROOT, 'pnpm-workspace.yaml');
114
- const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
114
+ // `init` (step 3) adds the entry of each optional package it installs on demand, e.g. ffmpeg-static.
115
+ const block = 'allowBuilds:\n esbuild: true\n';
115
116
  if (!fs.existsSync(file)) {
116
- fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
117
+ fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx) compile a binary on install.\n${block}`);
117
118
  return 'created';
118
119
  }
119
120
  const text = fs.readFileSync(file, 'utf8');
@@ -140,7 +141,8 @@ function open(target) {
140
141
  spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
141
142
  }
142
143
 
143
- // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
144
+ // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: `init` (step 3) installs the bundled
145
+ // ffmpeg-static when this machine has no ffmpeg.
144
146
  function ensureFfmpeg() {
145
147
  // ffmpeg does not know `--version`, only `-version`.
146
148
  if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
@@ -151,11 +153,11 @@ function ensureFfmpeg() {
151
153
  ? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
152
154
  : null;
153
155
  if (!installer) {
154
- return warn('ffmpeg not found; the bundled ffmpeg-static is used instead. To install it yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
156
+ return warn('ffmpeg not found; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3. To install ffmpeg yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
155
157
  }
156
158
  console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
157
159
  if (run(...installer)) console.log('ffmpeg: installed');
158
- else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
160
+ else warn('Could not install ffmpeg; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3.');
159
161
  }
160
162
 
161
163
  step(1, 'Checking this machine');
@@ -1,3 +1,4 @@
1
+ import { MARKER, PACKAGE } from '../lib/paths.js';
1
2
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
2
3
  export declare const GENERATED: {
3
4
  file: string;
@@ -8,9 +9,9 @@ export declare const ONCE: {
8
9
  file: string;
9
10
  template: string;
10
11
  }[];
11
- export declare const MARKER = ".asrun.json";
12
+ export { MARKER };
13
+ export { PACKAGE };
12
14
  export declare const DOTENV_HEADER = "# asrun: the workspace these commands run against (relative to this file).";
13
- export declare const PACKAGE = "@asrunjs/asrun";
14
15
  /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
15
16
  export declare function frameworkPath(root: string): string;
16
17
  /** True when this workspace has the package installed (or is the framework's own checkout). */