@asrunjs/asrun 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude/settings.json +5 -5
  2. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -103
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -128
  4. package/.claude/skills/making-cases/references/gaps.md +36 -36
  5. package/.claude/skills/making-cases/references/splitting.md +28 -28
  6. package/AGENTS.md +6 -0
  7. package/README.md +33 -9
  8. package/bin/asrun.mjs +19 -2
  9. package/dist/lib/ffmpeg.js +21 -10
  10. package/dist/lib/migrations.d.ts +106 -0
  11. package/dist/lib/migrations.js +106 -0
  12. package/dist/lib/paths.d.ts +9 -0
  13. package/dist/lib/paths.js +12 -1
  14. package/dist/lib/registry.d.ts +31 -0
  15. package/dist/lib/registry.js +66 -0
  16. package/dist/lib/runtime-deps.d.ts +59 -0
  17. package/dist/lib/runtime-deps.js +161 -0
  18. package/dist/lib/tar.d.ts +5 -0
  19. package/dist/lib/tar.js +79 -0
  20. package/dist/lib/update-check.d.ts +52 -0
  21. package/dist/lib/update-check.js +223 -0
  22. package/dist/migrations/index.d.ts +23 -0
  23. package/dist/migrations/index.js +22 -0
  24. package/dist/migrations/manifest.json +1 -0
  25. package/dist/scripts/build.js +7 -0
  26. package/dist/scripts/cli.js +33 -7
  27. package/dist/scripts/doctor.js +51 -9
  28. package/dist/scripts/go.mjs +43 -11
  29. package/dist/scripts/heal-evidence.mjs +149 -143
  30. package/dist/scripts/init.d.ts +9 -1
  31. package/dist/scripts/init.js +127 -23
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/reset.d.ts +1 -0
  35. package/dist/scripts/reset.js +233 -0
  36. package/dist/scripts/run.js +25 -24
  37. package/dist/scripts/upgrade.d.ts +1 -0
  38. package/dist/scripts/upgrade.js +256 -0
  39. package/dist/ui/server/app.js +18 -0
  40. package/dist/ui/server/index.js +3 -0
  41. package/dist/ui/server/update.d.ts +18 -0
  42. package/dist/ui/server/update.js +87 -0
  43. package/dist/ui/shared/types.d.ts +24 -0
  44. package/docs/agents/framework-dev.md +99 -1
  45. package/docs/commands.md +96 -9
  46. package/docs/install.md +92 -19
  47. package/docs/troubleshooting.md +43 -14
  48. package/package.json +35 -33
  49. package/templates/.claude/settings.json +5 -5
  50. package/templates/AGENTS.md +1 -0
  51. package/templates/CLAUDE.md +1 -1
  52. package/templates/gitignore +13 -13
  53. package/templates/package.json +33 -32
  54. package/templates/playwright.config.ts +3 -3
  55. package/templates/pnpm-workspace.yaml +8 -5
  56. package/templates/seed.spec.ts +9 -9
  57. package/templates/tsconfig.json +14 -14
  58. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
  59. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
  60. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
  61. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
  62. package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
  63. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
  64. package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
  65. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
  66. package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
  67. package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
  68. package/ui/dist/assets/index-Cy2l91qV.css +2 -0
  69. package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
  70. package/ui/dist/favicon.svg +11 -11
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/logos/claude.svg +1 -1
  73. package/ui/dist/logos/opencode.svg +1 -1
  74. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Packages the framework installs on demand, instead of depending on them.
3
+ *
4
+ * A runtime dependency that runs an install script makes `pnpm dlx @asrunjs/asrun` stop at pnpm's build-approval
5
+ * picker: the dlx sandbox does not read the workspace's `allowBuilds` (that file belongs to the project, not to the
6
+ * sandbox), and in a pipeline nobody can answer the picker. So an optional package stays out of `dependencies` and
7
+ * `optionalDependencies`, and `init` (also through `go`) installs it into the workspace as a devDependency when the
8
+ * machine does not already provide what it gives.
9
+ *
10
+ * To add one, append it to `RUNTIME_EXTRAS`: its `allowBuilds` entry is then written by `init`/`go` before the
11
+ * install, `doctor` warns when it is missing, and `verify:pack` checks that it never becomes a runtime dependency.
12
+ */
13
+ export interface RuntimeExtra {
14
+ /** the npm package, installed into the workspace on demand */
15
+ pkg: string;
16
+ /** one line for the person: what does not work without it */
17
+ provides: string;
18
+ /** a command that proves the machine already has the capability (exit code 0); empty means "never present" */
19
+ probe: string[];
20
+ /** the package runs an install script, so `pnpm-workspace.yaml` needs an `allowBuilds` entry for it */
21
+ builds: boolean;
22
+ }
23
+ export declare const RUNTIME_EXTRAS: RuntimeExtra[];
24
+ /** Every package whose install scripts pnpm may run: the `allowBuilds` block of `pnpm-workspace.yaml`. */
25
+ export declare const BUILD_APPROVALS: string[];
26
+ /** True when the machine already provides what that package would (its probe exits 0). */
27
+ export declare function hasCapability(extra: RuntimeExtra): boolean;
28
+ /** True when the workspace itself has the package (installed on demand, so a devDependency). */
29
+ export declare function installedInWorkspace(root: string, pkg: string): boolean;
30
+ /**
31
+ * Adds `<pkg>: true` to the `allowBuilds` block of the workspace's `pnpm-workspace.yaml`, before the package is
32
+ * installed. Text-level on purpose: the file belongs to the project, comments and order included.
33
+ *
34
+ * Returns how it ended up: `present` (already approved), `added`, `created` (the file had no block), `inline` (the
35
+ * block is a one-line map, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
36
+ */
37
+ export declare function approveBuild(root: string, pkg: string): 'present' | 'added' | 'created' | 'inline' | 'no-file';
38
+ /**
39
+ * Adds the package to `minimumReleaseAgeExclude` in the workspace's `pnpm-workspace.yaml`, so pnpm's release age does
40
+ * not hold a fresh release back while `pnpm asrun upgrade` installs it. Text-level, like `approveBuild`, because the
41
+ * file belongs to the project.
42
+ *
43
+ * Returns how it ended up: `present` (already listed), `added`, `created` (the file had no block), `inline` (the list
44
+ * is written inline, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
45
+ */
46
+ export declare function excludeReleaseAge(root: string, pkg: string): 'present' | 'added' | 'created' | 'inline' | 'no-file';
47
+ export interface ExtraOutcome {
48
+ extra: RuntimeExtra;
49
+ status: 'present' | 'installed' | 'failed';
50
+ /** why the install failed, for the message `init` prints */
51
+ detail?: string;
52
+ }
53
+ /**
54
+ * Installs every runtime extra the workspace still lacks, after approving its install script. The package becomes a
55
+ * devDependency of the workspace (`pnpm add -D`), which is what "on demand" means: a project that never needs the
56
+ * capability never downloads it. A failure is never fatal: the feature that needs the package degrades, and `init`
57
+ * tells the person.
58
+ */
59
+ export declare function ensureRuntimeExtras(root: string, onInstall?: (extra: RuntimeExtra) => void): ExtraOutcome[];
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnTarget } from './command.js';
5
+ export const RUNTIME_EXTRAS = [
6
+ {
7
+ pkg: 'ffmpeg-static',
8
+ provides: 'joining the recordings of a test that opened more than one browser tab into one video',
9
+ probe: ['ffmpeg', '-version'],
10
+ builds: true,
11
+ },
12
+ ];
13
+ /** Every package whose install scripts pnpm may run: the `allowBuilds` block of `pnpm-workspace.yaml`. */
14
+ export const BUILD_APPROVALS = [
15
+ // esbuild comes in through tsx, which a Git dependency builds with (`prepare`).
16
+ ...new Set(['esbuild', ...RUNTIME_EXTRAS.filter((extra) => extra.builds).map((extra) => extra.pkg)]),
17
+ ];
18
+ const escapePattern = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ /** True when the machine already provides what that package would (its probe exits 0). */
20
+ export function hasCapability(extra) {
21
+ if (extra.probe.length === 0)
22
+ return false;
23
+ // `spawnTarget` resolves a Windows `.cmd` shim (ffmpeg installed through winget or a wrapper script) without a shell.
24
+ const target = spawnTarget(extra.probe[0], extra.probe.slice(1));
25
+ if (target === null)
26
+ return false;
27
+ const result = spawnSync(target.file, target.args, {
28
+ stdio: 'ignore',
29
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
30
+ });
31
+ return result.status === 0;
32
+ }
33
+ /** True when the workspace itself has the package (installed on demand, so a devDependency). */
34
+ export function installedInWorkspace(root, pkg) {
35
+ return fs.existsSync(path.join(root, 'node_modules', pkg, 'package.json'));
36
+ }
37
+ /** A package name as a YAML mapping key: quoted when it starts with `@`, which YAML reads as a directive. */
38
+ const yamlKey = (pkg) => (pkg.startsWith('@') ? `'${pkg}'` : pkg);
39
+ /** The `allowBuilds` entry pattern of a package, for both quoted and plain keys. */
40
+ const approvalPattern = (pkg) => new RegExp(`^\\s+['"]?${escapePattern(pkg)}['"]?\\s*:`);
41
+ /**
42
+ * Adds `<pkg>: true` to the `allowBuilds` block of the workspace's `pnpm-workspace.yaml`, before the package is
43
+ * installed. Text-level on purpose: the file belongs to the project, comments and order included.
44
+ *
45
+ * Returns how it ended up: `present` (already approved), `added`, `created` (the file had no block), `inline` (the
46
+ * block is a one-line map, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
47
+ */
48
+ export function approveBuild(root, pkg) {
49
+ const file = path.join(root, 'pnpm-workspace.yaml');
50
+ let text;
51
+ try {
52
+ text = fs.readFileSync(file, 'utf8');
53
+ }
54
+ catch {
55
+ return 'no-file';
56
+ }
57
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
58
+ const lines = text.split(/\r?\n/);
59
+ const start = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
60
+ if (start === -1) {
61
+ const body = text.replace(/\s*$/, '');
62
+ fs.writeFileSync(file, `${body === '' ? '' : `${body}${eol}${eol}`}allowBuilds:${eol} ${yamlKey(pkg)}: true${eol}`);
63
+ return 'created';
64
+ }
65
+ if (/^allowBuilds\s*:\s*\S/.test(lines[start]))
66
+ return 'inline';
67
+ let end = start + 1;
68
+ for (let i = start + 1; i < lines.length; i++) {
69
+ // The block stops at the first line that is not indented (a blank line included).
70
+ if (lines[i].trim() === '' || !/^\s/.test(lines[i]))
71
+ break;
72
+ if (approvalPattern(pkg).test(lines[i]))
73
+ return 'present';
74
+ end = i + 1;
75
+ }
76
+ lines.splice(end, 0, ` ${yamlKey(pkg)}: true`);
77
+ fs.writeFileSync(file, lines.join(eol));
78
+ return 'added';
79
+ }
80
+ /**
81
+ * Adds the package to `minimumReleaseAgeExclude` in the workspace's `pnpm-workspace.yaml`, so pnpm's release age does
82
+ * not hold a fresh release back while `pnpm asrun upgrade` installs it. Text-level, like `approveBuild`, because the
83
+ * file belongs to the project.
84
+ *
85
+ * Returns how it ended up: `present` (already listed), `added`, `created` (the file had no block), `inline` (the list
86
+ * is written inline, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
87
+ */
88
+ export function excludeReleaseAge(root, pkg) {
89
+ const file = path.join(root, 'pnpm-workspace.yaml');
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(file, 'utf8');
93
+ }
94
+ catch {
95
+ return 'no-file';
96
+ }
97
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
98
+ const lines = text.split(/\r?\n/);
99
+ const start = lines.findIndex((line) => /^minimumReleaseAgeExclude\s*:/.test(line));
100
+ const entry = `'${pkg}'`;
101
+ if (start === -1) {
102
+ const body = text.replace(/\s*$/, '');
103
+ fs.writeFileSync(file, `${body === '' ? '' : `${body}${eol}${eol}`}minimumReleaseAgeExclude:${eol} - ${entry}${eol}`);
104
+ return 'created';
105
+ }
106
+ if (/^minimumReleaseAgeExclude\s*:\s*\S/.test(lines[start])) {
107
+ return lines[start].includes(entry) ? 'present' : 'inline';
108
+ }
109
+ let end = start + 1;
110
+ for (let i = start + 1; i < lines.length; i++) {
111
+ if (lines[i].trim() === '' || !/^\s/.test(lines[i]))
112
+ break;
113
+ if (lines[i].includes(entry))
114
+ return 'present';
115
+ end = i + 1;
116
+ }
117
+ lines.splice(end, 0, ` - ${entry}`);
118
+ fs.writeFileSync(file, lines.join(eol));
119
+ return 'added';
120
+ }
121
+ /**
122
+ * Installs every runtime extra the workspace still lacks, after approving its install script. The package becomes a
123
+ * devDependency of the workspace (`pnpm add -D`), which is what "on demand" means: a project that never needs the
124
+ * capability never downloads it. A failure is never fatal: the feature that needs the package degrades, and `init`
125
+ * tells the person.
126
+ */
127
+ export function ensureRuntimeExtras(root, onInstall = () => { }) {
128
+ const outcomes = [];
129
+ for (const extra of RUNTIME_EXTRAS) {
130
+ if (hasCapability(extra) || installedInWorkspace(root, extra.pkg)) {
131
+ outcomes.push({ extra, status: 'present' });
132
+ continue;
133
+ }
134
+ onInstall(extra);
135
+ // The approval has to be there first: pnpm 12 refuses an install script it has not approved.
136
+ approveBuild(root, extra.pkg);
137
+ const target = spawnTarget('pnpm', ['add', '-D', extra.pkg]);
138
+ const result = target === null
139
+ ? null
140
+ : spawnSync(target.file, target.args, {
141
+ cwd: root,
142
+ stdio: 'inherit',
143
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
144
+ });
145
+ const error = result?.error ?? undefined;
146
+ outcomes.push(result === null
147
+ ? { extra, status: 'failed', detail: 'pnpm was not found on PATH' }
148
+ : result.status === 0
149
+ ? { extra, status: 'installed' }
150
+ : {
151
+ extra,
152
+ status: 'failed',
153
+ detail: error !== undefined
154
+ ? error.code === 'ENOENT'
155
+ ? 'pnpm was not found on PATH'
156
+ : error.message
157
+ : `pnpm add exited with code ${result.status}`,
158
+ });
159
+ }
160
+ return outcomes;
161
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The content of `wanted` inside a tarball (gzipped or not), or null when the archive has no such file. Only regular
3
+ * files are considered; a directory entry of the same name is skipped.
4
+ */
5
+ export declare function readFileFromTarball(archive: Buffer, wanted: string): Buffer | null;
@@ -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');