@asrunjs/asrun 0.7.0 → 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 (56) hide show
  1. package/AGENTS.md +6 -3
  2. package/README.md +21 -11
  3. package/bin/asrun.mjs +18 -2
  4. package/dist/lib/ffmpeg.js +21 -10
  5. package/dist/lib/migrations.d.ts +106 -0
  6. package/dist/lib/migrations.js +106 -0
  7. package/dist/lib/paths.d.ts +9 -0
  8. package/dist/lib/paths.js +12 -1
  9. package/dist/lib/registry.d.ts +31 -0
  10. package/dist/lib/registry.js +66 -0
  11. package/dist/lib/runtime-deps.d.ts +59 -0
  12. package/dist/lib/runtime-deps.js +161 -0
  13. package/dist/lib/tar.d.ts +5 -0
  14. package/dist/lib/tar.js +79 -0
  15. package/dist/lib/update-check.d.ts +52 -0
  16. package/dist/lib/update-check.js +223 -0
  17. package/dist/migrations/index.d.ts +23 -0
  18. package/dist/migrations/index.js +22 -0
  19. package/dist/migrations/manifest.json +1 -0
  20. package/dist/scripts/build.js +7 -0
  21. package/dist/scripts/cli.js +22 -4
  22. package/dist/scripts/doctor.js +51 -9
  23. package/dist/scripts/go.mjs +7 -5
  24. package/dist/scripts/init.d.ts +3 -2
  25. package/dist/scripts/init.js +73 -11
  26. package/dist/scripts/migrate.d.ts +1 -0
  27. package/dist/scripts/migrate.js +259 -0
  28. package/dist/scripts/run.js +2 -1
  29. package/dist/scripts/upgrade.d.ts +1 -0
  30. package/dist/scripts/upgrade.js +256 -0
  31. package/dist/ui/server/app.js +18 -0
  32. package/dist/ui/server/index.js +3 -0
  33. package/dist/ui/server/update.d.ts +18 -0
  34. package/dist/ui/server/update.js +87 -0
  35. package/dist/ui/shared/types.d.ts +24 -0
  36. package/docs/agents/framework-dev.md +74 -4
  37. package/docs/commands.md +74 -4
  38. package/docs/install.md +52 -16
  39. package/docs/troubleshooting.md +15 -7
  40. package/package.json +2 -4
  41. package/templates/AGENTS.md +1 -0
  42. package/templates/pnpm-workspace.yaml +3 -3
  43. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
  44. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
  45. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
  46. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
  47. package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
  48. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
  49. package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
  50. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
  51. package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
  52. package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
  53. package/ui/dist/assets/index-Cy2l91qV.css +2 -0
  54. package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
  55. package/ui/dist/index.html +2 -2
  56. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -0,0 +1,256 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { spawnTarget } from '../lib/command.js';
6
+ import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, } from '../lib/migrations.js';
7
+ import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
8
+ import { releaseInfo, releaseManifest } from '../lib/registry.js';
9
+ import { excludeReleaseAge } from '../lib/runtime-deps.js';
10
+ import { frameworkPath, PACKAGE } from './init.js';
11
+ /**
12
+ * `pnpm asrun upgrade`: one command that installs the newest framework, refreshes the workspace files and applies the
13
+ * migrations the new release brings.
14
+ *
15
+ * The order matters. The migration manifest of the *target* release is read out of its tarball first, so breaking
16
+ * changes are known before anything is written; the install comes next, and `init --update`, `migrate` and `doctor`
17
+ * then run through the *newly installed* package, never through the code that is being replaced.
18
+ */
19
+ const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes]
20
+
21
+ --version <v> the release to install (default: latest); a dist-tag or a concrete version
22
+ --root <dir> the workspace to upgrade (default: the current workspace)
23
+ --dry-run print the target release and the migrations it brings, and change nothing
24
+ --yes install without asking (a breaking change, or a release that cannot be checked)
25
+
26
+ What it does, in this order:
27
+ 1. resolve the target release and read its migration manifest from the registry;
28
+ 2. show the breaking changes and the migrations between the workspace's baseline and that release;
29
+ 3. install @asrunjs/asrun at that version (a fresh release is exempted from pnpm's release age first);
30
+ 4. run "init --update", "migrate" and "doctor" through the installed package.
31
+
32
+ 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.`;
34
+ /** How a workspace reaches its install: the nearest `node_modules/@asrunjs/asrun` at or above the workspace root. */
35
+ function installedDir(root) {
36
+ for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
37
+ const candidate = path.join(dir, 'node_modules', PACKAGE);
38
+ if (fs.existsSync(path.join(candidate, 'package.json')))
39
+ return candidate;
40
+ if (path.dirname(dir) === dir)
41
+ return null;
42
+ }
43
+ }
44
+ /** The version of the package installed in that workspace, or null when it is not there. */
45
+ function installedVersion(dir) {
46
+ if (dir === null)
47
+ return null;
48
+ try {
49
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
50
+ return typeof manifest.version === 'string' ? manifest.version : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** A person types the word; without a terminal there is no confirmation, and the command refuses. */
57
+ async function confirm(question) {
58
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
59
+ return null;
60
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
61
+ try {
62
+ return (await rl.question(question)).trim().toLowerCase() === 'upgrade';
63
+ }
64
+ finally {
65
+ rl.close();
66
+ }
67
+ }
68
+ function printMigration(entry) {
69
+ const tags = [entry.breaking === true ? 'breaking' : '', entry.auto === true ? 'runs automatically' : 'needs a person']
70
+ .filter(Boolean)
71
+ .join(', ');
72
+ console.log(` ${entry.breaking === true ? '!' : ' '} ${entry.version} ${entry.summary}`);
73
+ console.log(` ${entry.id}${tags === '' ? '' : ` (${tags})`}`);
74
+ if (entry.appliesTo !== undefined)
75
+ console.log(` affects: ${entry.appliesTo}`);
76
+ if (entry.details !== undefined)
77
+ console.log(` detail: ${entry.details}`);
78
+ for (const line of entry.manual ?? [])
79
+ console.log(` - ${line}`);
80
+ }
81
+ export async function main(args) {
82
+ if (args.includes('--help')) {
83
+ console.log(USAGE);
84
+ return 0;
85
+ }
86
+ const value = (name) => {
87
+ const index = args.indexOf(name);
88
+ return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
89
+ };
90
+ const root = path.resolve(value('--root') ?? ROOT);
91
+ const dryRun = args.includes('--dry-run');
92
+ const yes = args.includes('--yes');
93
+ const spec = value('--version') ?? 'latest';
94
+ if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
95
+ console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun upgrade" in the project that uses ' +
96
+ 'asrun, or pass --root <dir>.');
97
+ return 1;
98
+ }
99
+ if (!fs.existsSync(path.join(root, MARKER))) {
100
+ console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
101
+ return 1;
102
+ }
103
+ console.log('[1/4] Checking the release');
104
+ const directory = installedDir(root);
105
+ const current = installedVersion(directory);
106
+ if (directory === null || current === null) {
107
+ // The framework's own checkout (examples/demo) has no installed package and must be updated with git; a project
108
+ // that never installed the framework has to add the dependency.
109
+ const checkout = !path.resolve(root, frameworkPath(root)).split(path.sep).includes('node_modules');
110
+ if (checkout) {
111
+ console.error(`${root} uses the framework's own checkout (${path.resolve(root, frameworkPath(root))}), not an installed ` +
112
+ 'package: there is nothing to install. Update that checkout with git.');
113
+ }
114
+ else {
115
+ console.error(`${PACKAGE} is not installed in ${root}. Run "pnpm add -D ${PACKAGE}@${spec}" and then "pnpm asrun init".`);
116
+ }
117
+ return 1;
118
+ }
119
+ const lookup = releaseInfo(PACKAGE, spec);
120
+ if ('error' in lookup) {
121
+ console.error(`Could not read ${PACKAGE}@${spec} from the registry: ${lookup.error}`);
122
+ console.error(`\nUpgrade by hand instead:\n pnpm add -D ${PACKAGE}@${spec}\n pnpm asrun init --update\n pnpm asrun migrate\n` +
123
+ ' pnpm asrun doctor');
124
+ return 1;
125
+ }
126
+ const target = lookup.info.version;
127
+ if (!isVersion(target) || !isVersion(current)) {
128
+ console.error(`The registry reported "${target}" and the workspace has "${current}"; expected versions.`);
129
+ return 1;
130
+ }
131
+ const order = compareVersions(target, current);
132
+ if (order === 0) {
133
+ console.log(`${PACKAGE} ${current} is already installed in ${root}.`);
134
+ console.log('To refresh the workspace files or apply migrations that are still pending, run "pnpm asrun init --update" ' +
135
+ 'and "pnpm asrun migrate".');
136
+ return 0;
137
+ }
138
+ if (order < 0) {
139
+ console.error(`${PACKAGE}@${spec} is ${target}, which is older than the installed ${current}: upgrade never downgrades. ` +
140
+ `Run "pnpm add -D ${PACKAGE}@${target}" yourself if that is what you want.`);
141
+ return 1;
142
+ }
143
+ // The registry serves the package as a tarball; the manifest in it is what makes the pre-install check possible.
144
+ const state = migrationState(readMarker(root), current);
145
+ let warning = null;
146
+ let pending = [];
147
+ try {
148
+ const manifest = await releaseManifest(lookup.info.tarball);
149
+ if (manifest === null) {
150
+ warning = `${PACKAGE}@${target} ships no migration manifest (it predates it), so nothing could be checked in advance.`;
151
+ }
152
+ else {
153
+ pending = pendingMigrations(manifest, state.since, target);
154
+ }
155
+ }
156
+ catch (error) {
157
+ warning = `the migration manifest of ${PACKAGE}@${target} could not be read (${error instanceof Error ? error.message : String(error)}), so nothing could be checked in advance.`;
158
+ }
159
+ console.log(`Workspace: ${root}`);
160
+ console.log(`asrun: ${current} -> ${target}`);
161
+ if (warning === null && pending.length === 0) {
162
+ console.log(`Migrations: none between ${state.since} and ${target}.`);
163
+ }
164
+ else if (pending.length > 0) {
165
+ console.log(`\nMigrations between ${state.since} and ${target}:`);
166
+ for (const entry of pending)
167
+ printMigration(entry);
168
+ }
169
+ if (warning !== null)
170
+ console.log(`\n ! Cannot check the workspace up front: ${warning}`);
171
+ const breaking = pending.filter((entry) => entry.breaking === true);
172
+ const needsConfirmation = breaking.length > 0 || warning !== null;
173
+ if (dryRun) {
174
+ console.log(`\nWould install ${PACKAGE}@${target}, then run "init --update", "migrate" and "doctor" ` +
175
+ `(${pending.filter((entry) => entry.auto === true).length} automatic migration(s)).`);
176
+ console.log('Nothing was installed (--dry-run).');
177
+ return 0;
178
+ }
179
+ if (needsConfirmation) {
180
+ const question = breaking.length > 0
181
+ ? `\nType "upgrade" to install ${PACKAGE}@${target} despite ${breaking.length} breaking change(s): `
182
+ : `\nType "upgrade" to install ${PACKAGE}@${target} without the pre-install check: `;
183
+ const answer = await confirm(question);
184
+ if (answer === null) {
185
+ console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes to do it.');
186
+ return 1;
187
+ }
188
+ if (!answer) {
189
+ console.error('Cancelled. Nothing was installed.');
190
+ return 1;
191
+ }
192
+ }
193
+ console.log(`\n[2/4] Installing ${PACKAGE}@${target}`);
194
+ const age = excludeReleaseAge(root, PACKAGE);
195
+ if (age === 'added' || age === 'created') {
196
+ console.log(` pnpm-workspace.yaml: ${PACKAGE} exempted from pnpm's release age`);
197
+ }
198
+ else if (age === 'inline') {
199
+ console.log(` ! pnpm-workspace.yaml has an inline "minimumReleaseAgeExclude": add ${PACKAGE} to it, or pnpm may install the ` +
200
+ 'previous release instead of the one just published.');
201
+ }
202
+ const pnpm = spawnTarget('pnpm', ['add', '-D', `${PACKAGE}@${target}`]);
203
+ if (pnpm === null) {
204
+ console.error('pnpm was not found on PATH: install it, then run "pnpm asrun upgrade" again.');
205
+ return 1;
206
+ }
207
+ const install = spawnSync(pnpm.file, pnpm.args, {
208
+ cwd: root,
209
+ stdio: 'inherit',
210
+ ...(pnpm.throughShell && { windowsVerbatimArguments: true }),
211
+ });
212
+ if (install.status !== 0) {
213
+ const error = install.error;
214
+ const reason = error !== undefined
215
+ ? error.code === 'ENOENT'
216
+ ? 'pnpm was not found on PATH'
217
+ : error.message
218
+ : install.signal !== null
219
+ ? `pnpm was stopped by ${install.signal}`
220
+ : `pnpm add exited with code ${install.status}`;
221
+ console.error(`\n ! ${PACKAGE}@${target} was not installed: ${reason}`);
222
+ console.error(` Nothing after this step ran. Fix the cause and run "pnpm asrun upgrade" again.`);
223
+ return 1;
224
+ }
225
+ // Everything from here on runs the package that was just installed: its templates, its migrations, its checks. The
226
+ // process that started this command is still the old code, and must not write the marker with the old version.
227
+ const installedAfter = installedDir(root);
228
+ const after = installedVersion(installedAfter);
229
+ if (after !== null && after !== target) {
230
+ console.log(`\n ! pnpm installed ${PACKAGE}@${after}, not @${target} (pnpm's release age can hold a fresh release back). ` +
231
+ 'The workspace is brought up to the version that is there; run "pnpm asrun upgrade" again later.');
232
+ }
233
+ const bin = installedAfter === null ? '' : path.join(installedAfter, 'bin', 'asrun.mjs');
234
+ if (bin === '' || !fs.existsSync(bin)) {
235
+ console.error(`\n ! The install finished, but the installed asrun command is missing (${bin || 'not found'}). ` +
236
+ 'Run "pnpm install", then "pnpm asrun init --update".');
237
+ return 1;
238
+ }
239
+ const env = { ...process.env, ASRUN_ROOT: root };
240
+ const run = (commandArgs) => spawnSync(process.execPath, [bin, ...commandArgs], { cwd: process.cwd(), stdio: 'inherit', env });
241
+ console.log(`\n[3/4] Refreshing the workspace files and applying migrations`);
242
+ const init = run(['init', '--root', root, '--update']);
243
+ if (init.status !== 0) {
244
+ console.error(`\n ! "init --update" failed. Fix the cause and run "pnpm asrun init --update", then "pnpm asrun migrate".`);
245
+ return 1;
246
+ }
247
+ const migrate = run(['migrate', '--root', root, '--yes']);
248
+ if (migrate.status !== 0) {
249
+ console.error(`\n ! "migrate" failed. Fix the cause and run "pnpm asrun migrate" again.`);
250
+ return 1;
251
+ }
252
+ console.log(`\n[4/4] Checking the workspace`);
253
+ const doctor = run(['doctor']);
254
+ console.log(`\nUpgraded ${PACKAGE} ${current} -> ${after ?? target} in ${root}.`);
255
+ return doctor.status ?? 0;
256
+ }
@@ -14,6 +14,7 @@ import { resolveInside, sendFile } from './files.js';
14
14
  import { JobConflictError, jobs } from './jobs.js';
15
15
  import { listAiModels } from './ai-models.js';
16
16
  import { fileEvents } from './watch-files.js';
17
+ import { refreshUpdate, updateEvents, updateInfo } from './update.js';
17
18
  const REPORTS_DIR = path.join(ROOT, 'reports');
18
19
  // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
19
20
  const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
@@ -228,6 +229,17 @@ export function createApp({ port }) {
228
229
  const image = resolveInside(base, file.slice('.explore/'.length));
229
230
  return image ? sendFile(c, image) : c.notFound();
230
231
  });
232
+ // The release check of this server run. `refresh=1` asks the registry again and answers with the result (a person
233
+ // testing the notice, or a page that wants a fresh answer); otherwise the page gets the current state, and the first
234
+ // page load starts the check if it has not finished yet.
235
+ api.get('/update', async (c) => {
236
+ if (c.req.query('refresh') === '1')
237
+ return c.json(await refreshUpdate(true));
238
+ const info = updateInfo();
239
+ if (info.checkedAt === null)
240
+ void refreshUpdate(true);
241
+ return c.json(info);
242
+ });
231
243
  api.get('/reports', (c) => {
232
244
  if (!fs.existsSync(REPORTS_DIR))
233
245
  return c.json([]);
@@ -288,12 +300,18 @@ export function createApp({ port }) {
288
300
  if (open)
289
301
  void stream.writeSSE({ event: 'files', data: JSON.stringify(change) }).catch(() => { });
290
302
  };
303
+ const updateListener = (info) => {
304
+ if (open)
305
+ void stream.writeSSE({ event: 'update', data: JSON.stringify(info) }).catch(() => { });
306
+ };
291
307
  jobs.events.on('job', listener);
292
308
  fileEvents.on('files', filesListener);
309
+ updateEvents.on('update', updateListener);
293
310
  stream.onAbort(() => {
294
311
  open = false;
295
312
  jobs.events.off('job', listener);
296
313
  fileEvents.off('files', filesListener);
314
+ updateEvents.off('update', updateListener);
297
315
  });
298
316
  while (open) {
299
317
  await stream.writeSSE({ event: 'ping', data: '' });
@@ -3,6 +3,7 @@ import { createApp } from './app.js';
3
3
  import { verifyProfiles } from './profiles.js';
4
4
  import { jobs } from './jobs.js';
5
5
  import { startFileWatcher } from './watch-files.js';
6
+ import { startUpdateWatcher } from './update.js';
6
7
  const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
7
8
  const app = createApp({ port });
8
9
  const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
@@ -15,6 +16,8 @@ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
15
16
  console.log(`asrun UI: http://127.0.0.1:${port}`);
16
17
  }
17
18
  startFileWatcher();
19
+ // Once now (the notice lands on this terminal), then at every UTC midnight; the top bar picks the answer up.
20
+ startUpdateWatcher();
18
21
  void verifyProfiles();
19
22
  });
20
23
  server.on('error', (error) => {
@@ -0,0 +1,18 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { UpdateInfo } from '../shared/types.js';
3
+ /**
4
+ * The release check of the web UI: once when the server starts, then once a day at 00:00 UTC. The answer is cached in
5
+ * `.tmp/` (see lib/update-check.ts), so several tabs and the command line share one registry read. Pages learn about a
6
+ * change through the `update` event of `/api/events`.
7
+ */
8
+ export declare const updateEvents: EventEmitter<[never]>;
9
+ /** What the UI should show now; before the first answer every field that needs one is null. */
10
+ export declare function updateInfo(): UpdateInfo;
11
+ /**
12
+ * Asks the registry again and tells the open pages. `force` goes past a fresh cache, which is what the startup and the
13
+ * daily checks do; the answer is written back to `.tmp/`, so the command line shares it. Overlapping calls share one
14
+ * read: the daily timer and the first page load can arrive together.
15
+ */
16
+ export declare function refreshUpdate(force?: boolean): Promise<UpdateInfo>;
17
+ /** Today's check, then one at every UTC midnight while the server runs. */
18
+ export declare function startUpdateWatcher(): void;
@@ -0,0 +1,87 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { checkForUpdate, formatNotice, upgradeCommand, useColor } from '../../lib/update-check.js';
3
+ /**
4
+ * The release check of the web UI: once when the server starts, then once a day at 00:00 UTC. The answer is cached in
5
+ * `.tmp/` (see lib/update-check.ts), so several tabs and the command line share one registry read. Pages learn about a
6
+ * change through the `update` event of `/api/events`.
7
+ */
8
+ export const updateEvents = new EventEmitter();
9
+ // One listener per open page; the default warning at ten tabs is noise.
10
+ updateEvents.setMaxListeners(0);
11
+ let status = null;
12
+ let inFlight = null;
13
+ /** The notice already printed, so a daily re-check of the same release does not repeat it. */
14
+ let printedNotice = null;
15
+ /** What the UI should show now; before the first answer every field that needs one is null. */
16
+ export function updateInfo() {
17
+ if (status === null) {
18
+ return {
19
+ current: null,
20
+ latest: null,
21
+ available: false,
22
+ checkedAt: null,
23
+ kind: null,
24
+ command: null,
25
+ disabled: false,
26
+ mocked: false,
27
+ error: null,
28
+ };
29
+ }
30
+ return {
31
+ current: status.current,
32
+ latest: status.latest,
33
+ available: status.available,
34
+ checkedAt: status.checkedAt,
35
+ kind: status.kind,
36
+ command: status.available ? upgradeCommand(status.kind) : null,
37
+ disabled: status.disabled,
38
+ mocked: status.mocked,
39
+ error: status.error,
40
+ };
41
+ }
42
+ /**
43
+ * Asks the registry again and tells the open pages. `force` goes past a fresh cache, which is what the startup and the
44
+ * daily checks do; the answer is written back to `.tmp/`, so the command line shares it. Overlapping calls share one
45
+ * read: the daily timer and the first page load can arrive together.
46
+ */
47
+ export function refreshUpdate(force = true) {
48
+ if (inFlight !== null)
49
+ return inFlight;
50
+ inFlight = (async () => {
51
+ try {
52
+ status = checkForUpdate({ force });
53
+ }
54
+ catch {
55
+ // checkForUpdate reports a failed read instead of throwing; if that ever changes, keep serving the last answer.
56
+ }
57
+ const answer = updateInfo();
58
+ const notice = status === null || status.disabled ? null : formatNotice(status, { color: useColor(process.stdout) });
59
+ // Printed on the terminal only when something changed: once at startup, and again for a newer release.
60
+ if (notice !== printedNotice && notice !== null) {
61
+ printedNotice = notice;
62
+ console.log(notice);
63
+ }
64
+ if (notice === null)
65
+ printedNotice = null;
66
+ updateEvents.emit('update', answer);
67
+ return answer;
68
+ })().finally(() => {
69
+ inFlight = null;
70
+ });
71
+ return inFlight;
72
+ }
73
+ /** Today's check, then one at every UTC midnight while the server runs. */
74
+ export function startUpdateWatcher() {
75
+ void refreshUpdate(true);
76
+ scheduleNextCheck();
77
+ }
78
+ function scheduleNextCheck() {
79
+ const now = new Date();
80
+ const nextMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1);
81
+ const timer = setTimeout(() => {
82
+ void refreshUpdate(true);
83
+ scheduleNextCheck();
84
+ }, Math.max(1_000, nextMidnight - now.getTime()));
85
+ // The check must never keep the server process alive on its own.
86
+ timer.unref();
87
+ }
@@ -332,6 +332,30 @@ export interface InfoResponse {
332
332
  suites: string[];
333
333
  caseIds: string[];
334
334
  }
335
+ /**
336
+ * What the web UI shows about a newer release. The first check of a server run may still be running, so every field
337
+ * that needs an answer is null until it finished.
338
+ */
339
+ export interface UpdateInfo {
340
+ /** The version of the framework this server runs; null until the first check finished. */
341
+ current: string | null;
342
+ /** The newest release on the registry; null when there is none (or the registry could not be read). */
343
+ latest: string | null;
344
+ /** True when `latest` is newer than `current`. */
345
+ available: boolean;
346
+ /** When the answer was fetched, ISO. A cached answer keeps the time of the fetch. */
347
+ checkedAt: string | null;
348
+ /** `installed` when the framework comes from the registry, `checkout` for the framework's own git repository. */
349
+ kind: 'installed' | 'checkout' | null;
350
+ /** The line to run to get the newest release; null when there is nothing to upgrade. */
351
+ command: string | null;
352
+ /** True when ASRUN_NO_UPDATE_CHECK turned the check off. */
353
+ disabled: boolean;
354
+ /** True when the answer is a development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`). */
355
+ mocked: boolean;
356
+ /** Why the last check could not read the registry; the UI stays quiet then. */
357
+ error: string | null;
358
+ }
335
359
  /** Everything that belongs to one case. Shown before a delete or rename, and returned by them. */
336
360
  export interface CaseImpact {
337
361
  id: string;
@@ -66,6 +66,74 @@ is relevant to a project that uses asrun as a dependency.
66
66
  - A project's rules never flow back here. The framework reads a workspace only through `ROOT` at run time; nothing
67
67
  under `templates/` or `.claude/skills/` is generated from a project.
68
68
 
69
+ ### Seeing the release notice without a release
70
+
71
+ `lib/update-check.ts` asks the registry for the newest version. To see the notice without publishing anything, make it
72
+ answer with a made-up version. The check is the same code the command line and the web UI run, so both show exactly
73
+ what a real release would produce.
74
+
75
+ ```powershell
76
+ # A framework checkout (the default in this repository): the notice says to update with git
77
+ $env:ASRUN_UPDATE_MOCK_LATEST = '9.9.9'
78
+ pnpm asrun doctor # stderr: ... Update the framework checkout: git pull
79
+ pnpm ui # top-bar badge, and the dialog offers git pull
80
+
81
+ # A registry install: the notice offers "pnpm asrun upgrade"
82
+ $env:ASRUN_UPDATE_MOCK_KIND = 'installed'
83
+ pnpm ui
84
+ ```
85
+
86
+ - `ASRUN_UPDATE_MOCK_LATEST=<version>` replaces the registry answer; `ASRUN_UPDATE_MOCK_KIND=installed|checkout`
87
+ replaces the install-kind detection. Each field falls back, in this order, to the environment, then to
88
+ `.tmp/update-mock.json` at the workspace root (in this repository that is `examples/demo/.tmp/update-mock.json`,
89
+ because `.env` points `ASRUN_ROOT` there; for example `{"latest": "9.9.9", "kind": "installed"}`). A mock is never
90
+ written to the `.tmp/update-check.json` cache, so deleting the variable or the file brings the real check straight
91
+ back.
92
+ - The environment is read when a process starts: a running `pnpm ui` must be restarted after changing it. The mock file
93
+ is read on every check, so editing it and pressing **Check again** (or `GET /api/update?refresh=1`) is enough.
94
+ - The UI marks a mocked answer with a **Dev mock** tag, and the API returns `mocked: true`.
95
+ - `ASRUN_NO_UPDATE_CHECK=1` still wins over both: it turns the notice off entirely.
96
+
97
+ `GET /api/update` answers with the state of the server's last check; `GET /api/update?refresh=1` checks again first
98
+ (it is what the dialog's **Check again** button calls).
99
+
100
+ ### Optional runtime packages
101
+
102
+ A dependency with an install script makes `pnpm dlx @asrunjs/asrun` stop at pnpm's build-approval picker: the dlx
103
+ sandbox does not read the workspace's `allowBuilds`, and in a pipeline nobody can answer the prompt. So a package that
104
+ is heavy and optional (the bundled `ffmpeg-static` today) is not a dependency at all:
105
+
106
+ - Add it to `RUNTIME_EXTRAS` in `lib/runtime-deps.ts`, with the line the person sees (`provides`), a `probe` command
107
+ that proves the machine already has the capability, and `builds: true` when the package runs an install script.
108
+ - `init` (and therefore `go`, whose third step runs it) writes the `allowBuilds` entry and installs the package as a
109
+ dependency of the workspace, only when the probe fails; `--no-extras` leaves it out. `BUILD_APPROVALS` is the one
110
+ list `doctor`, `go` and the template of `pnpm-workspace.yaml` follow.
111
+ - Resolve an extra from `ROOT` first, then from the framework's own install, the way `lib/ffmpeg.ts` does.
112
+ - `doctor` warns while an extra is missing, and `verify:pack` fails when one of them becomes a runtime dependency
113
+ again (that would bring the dlx prompt back). CI runs it on a packed tarball.
114
+
115
+ ### Workspace migrations
116
+
117
+ A release can require an *existing* workspace to change; `docs/commands.md` has the user's view. The registry is
118
+ `migrations/index.ts`, and the interface plus the helpers live in `lib/migrations.ts`:
119
+
120
+ - `version` is the release that introduces the change. `pnpm asrun migrate` runs an entry when
121
+ `migrations.since < version <= installed`, in version order, and moves `since` only after every entry of that
122
+ release has been processed, so a failed run repeats the release instead of skipping its other entries.
123
+ - `auto: true` with an `apply` runs after the install, when `detect` says the workspace is not migrated yet. Keep it
124
+ idempotent: a run that failed repeats the entry.
125
+ - Anything a person has to do is `manual: string[]`. The id is remembered in `.asrun.json` (`migrations.manual`) and
126
+ `doctor` keeps reminding until `pnpm asrun migrate --ack <id>`.
127
+ - `breaking: true` makes `pnpm asrun upgrade` ask before installing anything. The pre-install check reads
128
+ `dist/migrations/manifest.json` out of the target tarball, so `pnpm build` has to write it (`scripts/build.ts`
129
+ serialises `MIGRATIONS` with `toManifest`). `verify:pack` asserts that a packed tarball carries the manifest and that
130
+ the reader in `lib/tar.ts` finds it.
131
+ - Append only: never delete or renumber an entry, because a workspace on an older release may still need it. A change
132
+ that only concerns brand-new workspaces belongs in `templates/`, not in a migration.
133
+ - `scripts/migrate.ts` applies the *installed* package's registry, so it never runs an entry from a release that is not
134
+ installed. `scripts/upgrade.ts` installs first, then runs `init --update`, `migrate` and `doctor` through the newly
135
+ installed bin (`bin/asrun.mjs`), never through the process that started the upgrade.
136
+
69
137
  ### Installing this repository as a Git dependency (private)
70
138
 
71
139
  The npm package is the public channel for users. This repository is private, so a Git dependency works only on a
@@ -164,8 +232,10 @@ Every lead, label and tooltip is written for the person using the UI, never for
164
232
 
165
233
  Nothing bumps the version by hand. A merge to `main` runs `.github/workflows/release.yml`, and Release Please opens
166
234
  (or refreshes) one `chore(release): X.Y.Z` pull request holding the next `package.json` version and the matching
167
- `CHANGELOG.md` entry. Merging that pull request is the release: the same run tags the commit `vX.Y.Z` and publishes to
168
- npm.
235
+ `CHANGELOG.md` entry. Merging that pull request is the release: the same run tags the commit and publishes to npm.
236
+
237
+ - Tags carry the package component, which manifest mode adds by default: `asrun-v0.7.0`, not `v0.7.0`. The publish check
238
+ accepts either, so a hand-made `v0.7.0` still works.
169
239
 
170
240
  - The next version comes from the commit messages on `main`, and a squashed pull request title becomes that message.
171
241
  `fix:` is a patch, `feat:` a minor, `feat!:` a major (the project is past 0.x, so a breaking change releases
@@ -174,8 +244,8 @@ npm.
174
244
  - `templates/`, `docs/`, `ui/dist/` and `.claude/` are shipped files: a change to them needs `fix:` or `feat:`, not
175
245
  `docs:`, or no release is made and no workspace ever sees the new version (`pnpm asrun doctor` compares it).
176
246
  - `release.yml` is the only uploader. When an upload fails after the tag exists, run it by hand with the tag instead of
177
- re-running the failed job: `gh workflow run release.yml -f tag=v1.0.0` (`-f dry_run=true` packs without uploading).
178
- A re-run of the push run finds the release already made and skips publishing.
247
+ re-running the failed job: `gh workflow run release.yml -f tag=asrun-v1.0.0` (`-f dry_run=true` packs without
248
+ uploading). A re-run of the push run finds the release already made and skips publishing.
179
249
  - npm authentication is trusted publishing (OIDC, no secret): the workflow filename in the package's trusted publisher
180
250
  settings is `release.yml`. A private repository gets no provenance, so the package page shows no provenance badge.
181
251
 
package/docs/commands.md CHANGED
@@ -7,8 +7,10 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
7
7
 
8
8
  | I want to... | Command |
9
9
  |---|---|
10
- | Install / update the framework | `pnpm dlx @asrunjs/asrun init --install`, `pnpm add -D @asrunjs/asrun@latest` |
10
+ | Install the framework in a project | `pnpm dlx @asrunjs/asrun init --install` |
11
+ | Upgrade the framework and migrate the workspace | `pnpm asrun upgrade [--version <v>] [--dry-run]` |
11
12
  | Prepare or check the workspace | `pnpm asrun init [--update]`, `pnpm asrun doctor` |
13
+ | See or apply the workspace migrations after a manual install | `pnpm asrun migrate [--list] [--dry-run]` |
12
14
  | Start over with fresh dependencies | `pnpm asrun reset [--yes] [--state] [--lockfile]` |
13
15
  | Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
14
16
  | See which profiles are logged in | `pnpm status [--env <env>]` |
@@ -168,19 +170,87 @@ pnpm ui:dev # Vite on :5173 with hot reload, API in watch mode
168
170
  The UI only listens on `127.0.0.1`. Set `ASRUN_UI_PORT` to change the port (`ASRUN_UI_WEB_PORT` for the Vite dev
169
171
  server). See [web-ui.md](./web-ui.md).
170
172
 
173
+ The UI checks the registry for a newer release when it starts and then once a day at 00:00 UTC (the answer is cached in
174
+ `.tmp/`, so the command line shares it). A newer release is printed on the terminal at startup and shows up as a badge
175
+ in the top bar; click it to see the version and the command that upgrades this install. `pnpm asrun upgrade` is skipped
176
+ there (it reads the registry itself). Set `ASRUN_NO_UPDATE_CHECK=1` to switch the check off, for a pipeline or a machine
177
+ without a registry.
178
+
179
+ ## Upgrading the framework
180
+
181
+ ```bash
182
+ pnpm asrun upgrade
183
+ pnpm asrun upgrade --dry-run
184
+ pnpm asrun upgrade --version 0.9.0
185
+ ```
186
+
187
+ `upgrade` runs four steps, in this order:
188
+
189
+ 1. resolves the target release (`latest`, or the dist-tag/version `--version <v>` names) and prints
190
+ `installed -> target`;
191
+ 2. reads the **migration manifest of the target release out of its tarball, before installing anything**, and prints
192
+ the migrations and breaking changes between the workspace's baseline (`.asrun.json` → `migrations.since`) and that
193
+ release. A breaking change needs a person's confirmation (type `upgrade`); an unattended run passes `--yes`. A
194
+ release that ships no manifest (it predates the manifest) cannot be checked in advance: `upgrade` says so and asks
195
+ before it installs;
196
+ 3. installs `@asrunjs/asrun@<target>` (it first exempts a fresh release from pnpm's release age, or pnpm would install
197
+ the previous one);
198
+ 4. runs `init --update`, `migrate` and `doctor` through the package that was just installed.
199
+
200
+ `--dry-run` stops after step 2. `--version` pins a release; `upgrade` never downgrades.
201
+
202
+ Every `asrun` command also checks the registry for a newer release, at most once a day (the answer is cached in `.tmp/`
203
+ and shared with the web UI), and prints one line on stderr before it starts:
204
+ `@asrunjs/asrun 0.8.0 is available (installed 0.7.0). Upgrade: pnpm asrun upgrade`. On a terminal the line carries an
205
+ `UPDATE` badge and highlights the version and the command; a pipe or a log gets the plain sentence. Inside the
206
+ framework's own git checkout the line names `git pull` instead, because there is no installed package to upgrade. A
207
+ failed check is silent. Set `ASRUN_NO_UPDATE_CHECK=1` to switch it off; `NO_COLOR` and `FORCE_COLOR` decide the colors
208
+ as they do everywhere else.
209
+
210
+ ### Migrations
211
+
212
+ A migration is a change an existing workspace must make when the framework moves it forward: a config entry that
213
+ moved, a file whose shape changed, a dependency range that has to follow the peer dependency. Anything `init --update`
214
+ refreshes by itself is not a migration. The registry is `migrations/index.ts` in the framework package, and the state
215
+ lives in `.asrun.json`:
216
+
217
+ ```json
218
+ "migrations": {"since": "0.7.0", "manual": ["0.8.0-rename-..."]}
219
+ ```
220
+
221
+ `since` is the release the workspace matches. It only moves forward when a migration succeeds, so an interrupted run
222
+ can simply be repeated. A migration marked `auto` runs by itself; one with `manual` steps is printed and remembered
223
+ until a person does it and acknowledges it. `init --update` never advances the baseline, which is why a manual
224
+ `pnpm add -D @asrunjs/asrun@latest` followed by `pnpm asrun init --update` still leaves its migrations pending.
225
+
226
+ ```bash
227
+ pnpm asrun migrate # apply what the installed release brings
228
+ pnpm asrun migrate --dry-run # show it, change nothing
229
+ pnpm asrun migrate --list # every migration of the installed release, and its state
230
+ pnpm asrun migrate --ack <id> # a manual step is done (--ack-all for every one)
231
+ pnpm asrun migrate --to <v> # stop at that release
232
+ ```
233
+
234
+ `migrate` never installs anything, and refuses to migrate beyond the installed release: the code that applies a
235
+ migration is the installed package. `doctor` warns while migrations or manual steps are outstanding.
236
+
171
237
  ## Workspace
172
238
 
173
239
  ```bash
174
- pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--install [spec]]
240
+ pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--no-extras] [--install [spec]]
175
241
  pnpm asrun doctor
242
+ pnpm asrun upgrade [--version <v>] [--dry-run] [--yes]
243
+ pnpm asrun migrate [--list] [--dry-run] [--yes] [--ack <id>|--ack-all]
176
244
  pnpm asrun reset [--root <dir>] [--dry-run] [--yes] [--state] [--lockfile]
177
245
  ```
178
246
 
179
247
  `init` writes the workspace files; `--update` refreshes only the generated ones, and `--force` rewrites every file
180
248
  init writes (a single file with `--force <file>`). `--force` still keeps `profiles.yaml` and `package.json`; name
181
- `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`.
249
+ `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`. It also
250
+ adds the `allowBuilds` entries `pnpm-workspace.yaml` lacks, and installs the optional packages this machine lacks
251
+ (`ffmpeg-static`; `--no-extras` skips them).
182
252
  `doctor` checks profiles, `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the
183
- project rule table and the skill overrides.
253
+ project rule table, the skill overrides and the workspace migrations. `upgrade` and `migrate` are described above.
184
254
 
185
255
  `reset` removes `node_modules` and the files `init` writes, for a clean reinstall. It deletes a once-only file only
186
256
  when it is still identical to its template, so edited files stay; `cases/`, specs, baselines, `profiles.yaml`,