@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,233 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { FRAMEWORK_ROOT, ROOT } from '../lib/paths.js';
5
+ import { DOTENV_HEADER, GENERATED, MARKER, ONCE, PACKAGE, renderTemplate } from './init.js';
6
+ /**
7
+ * `pnpm asrun reset`: removes the installed dependencies and the files `init` wrote, so the workspace can be
8
+ * scaffolded again from scratch (`pnpm dlx @asrunjs/asrun init --install`). It never deletes the project's own
9
+ * content: cases, specs, profiles.yaml, package.json, skill overrides and scaffold files the project edited stay.
10
+ */
11
+ const USAGE = `Usage: pnpm asrun reset [--root <dir>] [--dry-run] [--yes] [--state] [--lockfile]
12
+
13
+ --root <dir> the workspace to reset (default: the current workspace)
14
+ --dry-run list what would be removed, and change nothing
15
+ --yes skip the confirmation prompt (for unattended runs)
16
+ --state also remove local state: .auth/, reports/, .tmp/, .explore/, test-results/,
17
+ playwright-report/, .playwright-mcp/
18
+ --lockfile also remove pnpm-lock.yaml
19
+
20
+ Always removed: node_modules/ and the files init writes (the generated config/commands, .asrun.json, the asrun block
21
+ of .gitignore, the ASRUN_ROOT line of .env). A file init writes is kept when the project edited it; profiles.yaml and
22
+ package.json always stay.`;
23
+ /** Local state, never committed: removed only with `--state`, because `.auth/` means logging in again. */
24
+ const STATE_DIRS = ['.auth', 'reports', '.tmp', '.explore', 'test-results', 'playwright-report', '.playwright-mcp'];
25
+ /** The block `init` writes into `.gitignore` (see `templates/gitignore`). */
26
+ const ASRUN_BLOCK = /# >>> asrun[\s\S]*?# <<< asrun\r?\n?/;
27
+ function samePath(left, right) {
28
+ const a = path.resolve(left);
29
+ const b = path.resolve(right);
30
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
31
+ }
32
+ function isInside(dir, parent) {
33
+ const relative = path.relative(path.resolve(parent), path.resolve(dir));
34
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
35
+ }
36
+ function isAsrunEnvLine(line) {
37
+ return line.trim() === DOTENV_HEADER || /^\s*(?:export\s+)?ASRUN_ROOT\s*=/.test(line);
38
+ }
39
+ /** Removes the asrun block; deletes the file when nothing else was in it. */
40
+ function cleanGitignore(file) {
41
+ const text = fs.readFileSync(file, 'utf8');
42
+ const next = text.replace(ASRUN_BLOCK, '').trimEnd();
43
+ if (next === '')
44
+ fs.rmSync(file, { force: true });
45
+ else
46
+ fs.writeFileSync(file, `${next}\n`);
47
+ }
48
+ /** Removes the asrun header and the ASRUN_ROOT line; deletes the file when nothing else was in it. */
49
+ function cleanDotEnv(file) {
50
+ const lines = fs
51
+ .readFileSync(file, 'utf8')
52
+ .split(/\r?\n/)
53
+ .filter((line) => !isAsrunEnvLine(line));
54
+ const next = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
55
+ if (next === '')
56
+ fs.rmSync(file, { force: true });
57
+ else
58
+ fs.writeFileSync(file, `${next}\n`);
59
+ }
60
+ export async function main(args) {
61
+ if (args.includes('--help')) {
62
+ console.log(USAGE);
63
+ return 0;
64
+ }
65
+ const value = (name) => {
66
+ const index = args.indexOf(name);
67
+ return index >= 0 ? args[index + 1] : undefined;
68
+ };
69
+ const root = path.resolve(value('--root') ?? ROOT);
70
+ const dryRun = args.includes('--dry-run');
71
+ const yes = args.includes('--yes');
72
+ const includeState = args.includes('--state');
73
+ const includeLockfile = args.includes('--lockfile');
74
+ if (samePath(root, FRAMEWORK_ROOT) || isInside(root, FRAMEWORK_ROOT)) {
75
+ console.error(`Refusing to reset ${root}: it is (inside) the framework's own checkout, not a workspace that installed asrun. ` +
76
+ 'Run this command in the project that uses asrun, or pass --root <dir>.');
77
+ return 1;
78
+ }
79
+ if (![MARKER, 'profiles.yaml'].some((marker) => fs.existsSync(path.join(root, marker)))) {
80
+ console.error(`No workspace at ${root}: neither ${MARKER} nor profiles.yaml is there. ` +
81
+ 'Point --root at the project that uses asrun.');
82
+ return 1;
83
+ }
84
+ const planned = [];
85
+ const keptEdited = [];
86
+ const leftInPlace = [];
87
+ const fileItem = (relative) => {
88
+ const target = path.join(root, relative);
89
+ if (!fs.existsSync(target))
90
+ return null;
91
+ return { label: relative, apply: () => fs.rmSync(target, { force: true }) };
92
+ };
93
+ const dirItem = (relative) => {
94
+ const target = path.join(root, relative);
95
+ if (!fs.existsSync(target))
96
+ return null;
97
+ return {
98
+ label: `${relative}/`,
99
+ apply: () => fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
100
+ };
101
+ };
102
+ // The installed dependencies (the framework included), so `pnpm install` starts from nothing.
103
+ const deps = dirItem('node_modules');
104
+ if (deps)
105
+ planned.push(deps);
106
+ const lockfile = path.join(root, 'pnpm-lock.yaml');
107
+ if (fs.existsSync(lockfile)) {
108
+ if (includeLockfile)
109
+ planned.push({ label: 'pnpm-lock.yaml', apply: () => fs.rmSync(lockfile, { force: true }) });
110
+ else
111
+ leftInPlace.push('pnpm-lock.yaml (remove with --lockfile)');
112
+ }
113
+ // The files `init` generates, always rewritten by the next `init`.
114
+ for (const entry of GENERATED) {
115
+ const item = fileItem(entry.file);
116
+ if (item)
117
+ planned.push(item);
118
+ }
119
+ // The files `init` creates once. Only a file still identical to its template is the scaffold's; an edited file
120
+ // (AGENTS.md with project rules, a customized tsconfig.json) belongs to the project and stays.
121
+ for (const entry of ONCE) {
122
+ // The manifest is the project's own even when init created it: init only merges the scripts and devDependencies.
123
+ if (entry.file === 'package.json')
124
+ continue;
125
+ const target = path.join(root, entry.file);
126
+ if (!fs.existsSync(target))
127
+ continue;
128
+ let unchanged = false;
129
+ try {
130
+ unchanged = fs.readFileSync(target, 'utf8') === renderTemplate(entry.template, root);
131
+ }
132
+ catch {
133
+ // Unreadable: treat it as the project's and leave it alone.
134
+ }
135
+ if (unchanged)
136
+ planned.push({ label: entry.file, apply: () => fs.rmSync(target, { force: true }) });
137
+ else
138
+ keptEdited.push(entry.file);
139
+ }
140
+ const marker = fileItem(MARKER);
141
+ if (marker)
142
+ planned.push(marker);
143
+ const gitignore = path.join(root, '.gitignore');
144
+ if (fs.existsSync(gitignore) && ASRUN_BLOCK.test(fs.readFileSync(gitignore, 'utf8'))) {
145
+ planned.push({ label: '.gitignore (the asrun block)', apply: () => cleanGitignore(gitignore) });
146
+ }
147
+ const dotenv = path.join(root, '.env');
148
+ if (fs.existsSync(dotenv) && fs.readFileSync(dotenv, 'utf8').split(/\r?\n/).some(isAsrunEnvLine)) {
149
+ planned.push({ label: '.env (the asrun lines)', apply: () => cleanDotEnv(dotenv) });
150
+ }
151
+ for (const relative of STATE_DIRS) {
152
+ const target = path.join(root, relative);
153
+ if (!fs.existsSync(target))
154
+ continue;
155
+ if (includeState) {
156
+ const item = dirItem(relative);
157
+ if (item)
158
+ planned.push(item);
159
+ }
160
+ else {
161
+ leftInPlace.push(`${relative}/ (remove with --state)`);
162
+ }
163
+ }
164
+ if (planned.length === 0) {
165
+ console.log(`Nothing to reset in ${root}.`);
166
+ if (keptEdited.length > 0)
167
+ console.log(`Kept (edited by the project): ${keptEdited.join(', ')}`);
168
+ for (const note of leftInPlace)
169
+ console.log(`Left in place: ${note}`);
170
+ return 0;
171
+ }
172
+ console.log(`Workspace: ${root}`);
173
+ console.log(dryRun ? '\nWould remove:' : '\nThis removes:');
174
+ for (const item of planned)
175
+ console.log(` - ${item.label}`);
176
+ if (keptEdited.length > 0)
177
+ console.log(`\nKept (edited by the project): ${keptEdited.join(', ')}`);
178
+ for (const note of leftInPlace)
179
+ console.log(`Left in place: ${note}`);
180
+ if (includeState && fs.existsSync(path.join(root, '.auth'))) {
181
+ console.log('\n! .auth/ goes too: every profile must be signed in again.');
182
+ }
183
+ if (dryRun) {
184
+ console.log('\nNothing was removed (--dry-run).');
185
+ return 0;
186
+ }
187
+ if (!yes) {
188
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
189
+ console.error('\nRefusing to remove anything: there is no terminal to confirm in. A person adds --yes to do it.');
190
+ return 1;
191
+ }
192
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
193
+ const answer = (await rl.question('\nType "yes" to remove them: ')).trim().toLowerCase();
194
+ rl.close();
195
+ if (answer !== 'yes') {
196
+ console.error('Cancelled. Nothing was removed.');
197
+ return 1;
198
+ }
199
+ }
200
+ let failed = 0;
201
+ const removed = [];
202
+ for (const item of planned) {
203
+ try {
204
+ item.apply();
205
+ removed.push(item.label);
206
+ }
207
+ catch (error) {
208
+ failed += 1;
209
+ console.error(` ! ${item.label}: ${error instanceof Error ? error.message : String(error)}`);
210
+ }
211
+ }
212
+ // Folders the removals left empty (a workspace with no cases, or no project overrides) are part of the scaffold too.
213
+ for (const relative of ['cases', 'tests', '.claude/commands', '.opencode/commands', '.claude', '.opencode']) {
214
+ const target = path.join(root, relative);
215
+ try {
216
+ if (fs.existsSync(target) && fs.readdirSync(target).length === 0) {
217
+ fs.rmdirSync(target);
218
+ removed.push(`${relative}/`);
219
+ }
220
+ }
221
+ catch {
222
+ // not empty, or in use: leave it
223
+ }
224
+ }
225
+ if (failed > 0) {
226
+ console.error(`\n${failed} item(s) could not be removed (a file may be in use). Close what uses the workspace and run ` +
227
+ '"pnpm asrun reset" again.');
228
+ return 1;
229
+ }
230
+ console.log(`\nRemoved ${removed.length} item(s).`);
231
+ console.log(`\nNext:\n pnpm dlx ${PACKAGE} init --install\n pnpm go`);
232
+ return 0;
233
+ }
@@ -12,29 +12,29 @@ import { describeLeftovers, findLeftovers } from '../lib/records.js';
12
12
  import { filterArgs } from '../lib/run-filters.js';
13
13
  import { ALLOW_WRITES_ENV, describeSkippedWriteCases, describeWriteCases, findWriteCases, isProtectedEnv, planRun, } from '../lib/write-gate.js';
14
14
  import { checkSession, formatNotLoggedIn, NOT_LOGGED_IN_ENV, skipProfileHint, } from '../lib/session.js';
15
- const USAGE = `Usage: pnpm asrun --env <env> [--profile a,b] [--skip-profile a,b] [--tag name] [--logged-in] [--suite name] [--grep text] [--read-only] [--allow-writes=<env>] [--update-baselines <case,case>] [--report-id <id>] [playwright options...]
16
-
17
- --env is required and has no default (the environments are listed in profiles.yaml, e.g. production, staging).
18
-
19
- pnpm asrun --env staging all profiles, all cases
20
- pnpm asrun --env production --profile example-production one profile
21
- pnpm asrun --env production --skip-profile example-production all profiles except these
22
- pnpm asrun --env production --tag us only the profiles that carry the tag "us"
23
- pnpm asrun --env production --logged-in only the profiles that are logged in (the others are left out, not failed)
24
- pnpm asrun --env production --suite smoke only cases whose front matter lists "smoke" under "suites"
25
- (comma-separated for several: --suite smoke,regression; a case in
26
- both runs once). --priority is the old name of --suite.
27
- pnpm asrun --env production --read-only leave out the cases that write data (front matter "writes")
28
- pnpm asrun --env production --allow-writes=production run cases that write data in production WITHOUT the prompt. For
29
- people running unattended; AI agents must never add this flag.
30
- pnpm asrun --env production --grep landing one case (matches the test title)
31
- pnpm asrun --env staging --grep documents --update-baselines documents/landing-page
32
- run the chosen cases as usual, except the listed ones: those are run separately
33
- and their screenshot baselines are overwritten with what the page shows now
34
- (--update-snapshots=all). Look at the new images afterwards:
35
- pnpm baselines:review <case-id>
36
- pnpm asrun --env staging --ui interactive mode
37
- pnpm asrun --env production --step-text overlay the current test step on the videos
15
+ const USAGE = `Usage: pnpm asrun --env <env> [--profile a,b] [--skip-profile a,b] [--tag name] [--logged-in] [--suite name] [--grep text] [--read-only] [--allow-writes=<env>] [--update-baselines <case,case>] [--report-id <id>] [playwright options...]
16
+
17
+ --env is required and has no default (the environments are listed in profiles.yaml, e.g. production, staging).
18
+
19
+ pnpm asrun --env staging all profiles, all cases
20
+ pnpm asrun --env production --profile example-production one profile
21
+ pnpm asrun --env production --skip-profile example-production all profiles except these
22
+ pnpm asrun --env production --tag us only the profiles that carry the tag "us"
23
+ pnpm asrun --env production --logged-in only the profiles that are logged in (the others are left out, not failed)
24
+ pnpm asrun --env production --suite smoke only cases whose front matter lists "smoke" under "suites"
25
+ (comma-separated for several: --suite smoke,regression; a case in
26
+ both runs once). --priority is the old name of --suite.
27
+ pnpm asrun --env production --read-only leave out the cases that write data (front matter "writes")
28
+ pnpm asrun --env production --allow-writes=production run cases that write data in production WITHOUT the prompt. For
29
+ people running unattended; AI agents must never add this flag.
30
+ pnpm asrun --env production --grep landing one case (matches the test title)
31
+ pnpm asrun --env staging --grep documents --update-baselines documents/landing-page
32
+ run the chosen cases as usual, except the listed ones: those are run separately
33
+ and their screenshot baselines are overwritten with what the page shows now
34
+ (--update-snapshots=all). Look at the new images afterwards:
35
+ pnpm baselines:review <case-id>
36
+ pnpm asrun --env staging --ui interactive mode
37
+ pnpm asrun --env production --step-text overlay the current test step on the videos
38
38
  (also --step-text=1|true|yes|on; off by default; 0|false|no|off disables)`;
39
39
  const TRUE_VALUES = ['1', 'true', 'yes', 'on'];
40
40
  const FALSE_VALUES = ['0', 'false', 'no', 'off'];
@@ -147,7 +147,8 @@ const knownEnvs = listEnvs(allProfiles);
147
147
  const listOnly = passthrough.includes('--list');
148
148
  if (!env && !listOnly) {
149
149
  console.error(`Choose the environment to test: --env <env> (${knownEnvs.join(' | ')}). There is no default.\n` +
150
- `Example: pnpm asrun --env ${knownEnvs.includes('staging') ? 'staging' : (knownEnvs[0] ?? '<env>')}`);
150
+ `Example: pnpm asrun --env ${knownEnvs.includes('staging') ? 'staging' : (knownEnvs[0] ?? '<env>')}\n` +
151
+ 'All options: pnpm asrun --help. To check the installation instead: pnpm asrun doctor');
151
152
  process.exit(1);
152
153
  }
153
154
  if (env && !knownEnvs.includes(env)) {
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): Promise<number>;
@@ -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;