@asrunjs/asrun 0.6.1 → 0.7.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.
@@ -3,14 +3,17 @@ import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
5
5
  /**
6
- * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
7
- * `--update` refreshes only the files generated from the framework templates.
6
+ * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns,
7
+ * `--update` refreshes only the files generated from the framework templates, and `--force` rewrites every file it
8
+ * writes (except the project's own data and, without `--reset-rules`, its rules).
8
9
  */
9
- const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--no-env] [--install [spec]]
10
11
 
11
12
  --root <dir> the workspace to initialize (default: the current folder)
12
13
  --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
13
14
  .opencode/commands/*)
15
+ --force overwrite every file this tool writes, for a clean installation (keeps the project's own data:
16
+ profiles.yaml, package.json; AGENTS.md and CLAUDE.md need --reset-rules)
14
17
  --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
15
18
  --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
16
19
  --no-env do not write .env (ASRUN_ROOT)
@@ -30,7 +33,7 @@ export const GENERATED = [
30
33
  { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
31
34
  ];
32
35
  /** Files `init` creates once; after that the project owns them and may edit them freely. */
33
- const ONCE = [
36
+ export const ONCE = [
34
37
  { file: 'AGENTS.md', template: 'AGENTS.md' },
35
38
  { file: 'CLAUDE.md', template: 'CLAUDE.md' },
36
39
  { file: '.mcp.json', template: '.mcp.json' },
@@ -44,8 +47,8 @@ const ONCE = [
44
47
  ];
45
48
  // npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
46
49
  const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
47
- const MARKER = '.asrun.json';
48
- const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
50
+ export const MARKER = '.asrun.json';
51
+ export const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
49
52
  export const PACKAGE = '@asrunjs/asrun';
50
53
  function readTemplate(name) {
51
54
  return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
@@ -219,9 +222,22 @@ export async function main(args) {
219
222
  const installAt = args.indexOf('--install');
220
223
  const install = installAt >= 0;
221
224
  const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
222
- const forced = args
223
- .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
224
- .map((file) => file.split('\\').join('/'));
225
+ // `--force` alone overwrites every file init writes; `--force <path>` still names one file, so the messages the
226
+ // docs and doctor print keep working. The next token is a path only when it does not look like another flag.
227
+ const forced = [];
228
+ let forceAll = false;
229
+ for (let index = 0; index < args.length; index++) {
230
+ if (args[index] !== '--force')
231
+ continue;
232
+ const next = args[index + 1];
233
+ if (next !== undefined && !next.startsWith('-')) {
234
+ forced.push(next.split('\\').join('/'));
235
+ index++;
236
+ }
237
+ else {
238
+ forceAll = true;
239
+ }
240
+ }
225
241
  const resetRules = args.includes('--reset-rules');
226
242
  const skipEnv = args.includes('--no-env');
227
243
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
@@ -234,8 +250,9 @@ export async function main(args) {
234
250
  const kept = [];
235
251
  const notes = [];
236
252
  const framework = frameworkPath(root);
237
- // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
238
- // `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need `--reset-rules` on top.
253
+ // `--force` repairs a file this tool writes; without a path it rewrites every one of them. A project's own rules
254
+ // are never overwritten by a framework update: `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need
255
+ // `--reset-rules` on top. The workspace's own data (`profiles.yaml`, `package.json`) is only replaced when named.
239
256
  const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
240
257
  const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
241
258
  for (const file of forced) {
@@ -259,14 +276,26 @@ export async function main(args) {
259
276
  written.push(entry.file);
260
277
  };
261
278
  for (const entry of GENERATED)
262
- place(entry, update || forced.includes(entry.file));
279
+ place(entry, update || forceAll || forced.includes(entry.file));
280
+ const keptProjectData = [];
281
+ const keptRules = [];
263
282
  for (const entry of ONCE) {
264
283
  // The demo workspace lives inside the framework repository: it has no package manager settings of its own.
265
284
  if (framework.startsWith('..') && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))
266
285
  continue;
267
- place(entry, forced.includes(entry.file));
286
+ const explicit = forced.includes(entry.file);
287
+ const projectData = entry.file === 'profiles.yaml' || entry.file === 'package.json';
288
+ const rules = projectRules(entry.file);
289
+ const overwrite = explicit || (forceAll && !projectData && (!rules || resetRules));
290
+ if (forceAll && !explicit && fs.existsSync(path.join(root, entry.file))) {
291
+ if (projectData)
292
+ keptProjectData.push(entry.file);
293
+ if (rules && !resetRules)
294
+ keptRules.push(entry.file);
295
+ }
296
+ place(entry, overwrite);
268
297
  }
269
- const gitignore = ensureGitignore(root, update);
298
+ const gitignore = ensureGitignore(root, update || forceAll);
270
299
  const merged = mergePackageJson(root);
271
300
  const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
272
301
  if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
@@ -280,6 +309,10 @@ export async function main(args) {
280
309
  if (!workspace.includes('allowBuilds')) {
281
310
  notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
282
311
  }
312
+ if (!workspace.includes('minimumReleaseAgeExclude')) {
313
+ notes.push('Add an "@asrunjs/asrun" entry to "minimumReleaseAgeExclude" in pnpm-workspace.yaml, or pnpm installs the ' +
314
+ 'previous release for 24 hours after a new one.');
315
+ }
283
316
  if (/^packages\s*:/m.test(workspace)) {
284
317
  notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
285
318
  }
@@ -305,6 +338,15 @@ export async function main(args) {
305
338
  notes.push('Add "pnpm asrun *" to sandbox.excludedCommands in .claude/settings.json (see templates/.claude/settings.json).');
306
339
  }
307
340
  }
341
+ if (keptRules.length > 0) {
342
+ notes.push(`${keptRules.join(', ')} kept: the project owns them. Add --reset-rules to replace them with the template.`);
343
+ }
344
+ if (keptProjectData.length > 0) {
345
+ const profileHint = keptProjectData.includes('profiles.yaml')
346
+ ? ' Use "pnpm asrun init --force profiles.yaml" to replace the profile file.'
347
+ : '';
348
+ notes.push(`${keptProjectData.join(', ')} kept: --force never replaces the workspace's own data.${profileHint}`);
349
+ }
308
350
  // Install last, so pnpm-workspace.yaml (the build-script approval) already exists. Its absence is the reason
309
351
  // "pnpm add -D @asrunjs/asrun" fails on pnpm 12 with ERR_PNPM_IGNORED_BUILDS.
310
352
  const installed = isInstalled(root);
@@ -340,7 +382,7 @@ export async function main(args) {
340
382
  generated: GENERATED.map((entry) => entry.file),
341
383
  updatedAt: new Date().toISOString(),
342
384
  }, null, 2) + '\n');
343
- const label = firstRun ? 'Initialized' : update ? 'Refreshed' : 'Checked';
385
+ const label = firstRun ? 'Initialized' : forceAll ? 'Forced' : update ? 'Refreshed' : 'Checked';
344
386
  console.log(`${label} workspace: ${root}`);
345
387
  console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
346
388
  if (written.length > 0)
@@ -0,0 +1 @@
1
+ export declare function main(args: string[]): Promise<number>;
@@ -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'];
@@ -44,10 +44,19 @@ is relevant to a project that uses asrun as a dependency.
44
44
 
45
45
  `pnpm demo:reset` deletes the database and seeds it again; logins must be redone afterwards. Demo accounts are in
46
46
  `examples/demo/README.md`.
47
+
48
+ `.env` is per machine and gitignored, so a fresh clone has none: `init --root examples/demo` writes it in the folder
49
+ the command runs in (`ASRUN_ROOT=examples/demo`), and `pnpm go` does the same in its third step, so no manual step is
50
+ needed. Without it `pnpm go` still finds the demo through its fallback, but the other commands (`pnpm asrun`,
51
+ `pnpm signin`, `pnpm explore`) resolve the workspace from `ASRUN_ROOT` and then from the current folder, and stop
52
+ with "No workspace found: this is the framework folder".
47
53
  - Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
48
54
  assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
49
55
  `FRAMEWORK_ROOT` at run time.
50
56
  - After changing `lib/`, run `pnpm build` before exercising the demo's specs.
57
+ - `pnpm build` copies `scripts/*.mjs` into `dist/scripts/`, where they sit one folder deeper than in this repository.
58
+ `pnpm verify:pack` (part of CI) checks that layout on a packed tarball; the checkout alone hides such bugs, because
59
+ there `scripts/go.mjs` is next to `bin/asrun.mjs`.
51
60
  - `templates/` is the source of the files a workspace gets. When a template changes, bump `version` in
52
61
  `package.json` so `pnpm asrun doctor` in a project shows that it is behind.
53
62
  - `init` owns two lists: `GENERATED` (rewritten by `--update`; the commands and the Playwright files) and `ONCE`
@@ -151,6 +160,25 @@ Every lead, label and tooltip is written for the person using the UI, never for
151
160
  - **Say what the user sees.** "The run appears in the Jobs bar", not "the command runs in a one-shot session"; "the
152
161
  browser shows a small card", not "the agent pauses on a `writes` step".
153
162
 
163
+ ## Releases
164
+
165
+ Nothing bumps the version by hand. A merge to `main` runs `.github/workflows/release.yml`, and Release Please opens
166
+ (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.
169
+
170
+ - The next version comes from the commit messages on `main`, and a squashed pull request title becomes that message.
171
+ `fix:` is a patch, `feat:` a minor, `feat!:` a major (the project is past 0.x, so a breaking change releases
172
+ `1.0.0`). `chore:`, `ci:` and the rest ship nothing and open no release at all.
173
+ `.github/workflows/pr-title.yml` enforces the prefixes.
174
+ - `templates/`, `docs/`, `ui/dist/` and `.claude/` are shipped files: a change to them needs `fix:` or `feat:`, not
175
+ `docs:`, or no release is made and no workspace ever sees the new version (`pnpm asrun doctor` compares it).
176
+ - `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.
179
+ - npm authentication is trusted publishing (OIDC, no secret): the workflow filename in the package's trusted publisher
180
+ settings is `release.yml`. A private repository gets no provenance, so the package page shows no provenance badge.
181
+
154
182
  ## Before you open a pull request
155
183
 
156
184
  ```bash
@@ -162,6 +190,6 @@ pnpm ui:build # ui/dist/ must match ui/web and ui/server
162
190
  ```
163
191
 
164
192
  - `pnpm build` and `pnpm ui:build` also run from `prepare` and `prepack`, so a published package is complete.
165
- - Bump `version` when `templates/` or the shipped docs change; `pnpm asrun doctor` in a project compares it.
193
+ - The version is not bumped by hand (see Releases).
166
194
  - Keep the demo's committed expectations (`profiles.yaml`, `cases/`, `tests/`, baselines) in step with the demo site.
167
195
  Baselines are a person's decision: never let an agent regenerate them.
package/docs/commands.md CHANGED
@@ -9,6 +9,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
9
9
  |---|---|
10
10
  | Install / update the framework | `pnpm dlx @asrunjs/asrun init --install`, `pnpm add -D @asrunjs/asrun@latest` |
11
11
  | Prepare or check the workspace | `pnpm asrun init [--update]`, `pnpm asrun doctor` |
12
+ | Start over with fresh dependencies | `pnpm asrun reset [--yes] [--state] [--lockfile]` |
12
13
  | Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
13
14
  | See which profiles are logged in | `pnpm status [--env <env>]` |
14
15
  | Log in a profile (a person, with 2FA) | `pnpm signin <profile-id>` |
@@ -150,14 +151,18 @@ pnpm cleanup --env staging --forget
150
151
 
151
152
  A write case registers everything it creates (named `[asrun] ...`) and removes it after the test, also after a
152
153
  failure. Whatever could not be removed is listed in the report and by `cleanup`. Delete it by hand in the product,
153
- then run `--forget` so it is not listed again. `--env` is required.
154
+ then run `--forget` so it is not listed again. `--env` is required. `cleanup` is about test data in the product;
155
+ `pnpm asrun reset` (below) is the command that removes workspace files and dependencies.
154
156
 
155
157
  ## Web UI
156
158
 
157
159
  ```bash
158
- pnpm go # install + build + start + open the browser (the usual command)
159
- pnpm ui # build the web app and serve http://127.0.0.1:4173
160
- pnpm ui:dev # Vite on :5173 with hot reload, API in watch mode
160
+ pnpm go # install + build + start + open the browser (the usual command)
161
+ pnpm go --no-open # same, without opening a browser
162
+ pnpm go --no-ui # install and prepare the workspace, then stop (no server)
163
+ pnpm go --dry-run # only print the resolved workspace, framework and asrun command
164
+ pnpm ui # build the web app and serve http://127.0.0.1:4173
165
+ pnpm ui:dev # Vite on :5173 with hot reload, API in watch mode
161
166
  ```
162
167
 
163
168
  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
@@ -166,10 +171,22 @@ server). See [web-ui.md](./web-ui.md).
166
171
  ## Workspace
167
172
 
168
173
  ```bash
169
- pnpm asrun init [--update] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--install [spec]]
174
+ pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--install [spec]]
170
175
  pnpm asrun doctor
176
+ pnpm asrun reset [--root <dir>] [--dry-run] [--yes] [--state] [--lockfile]
171
177
  ```
172
178
 
173
- `init` writes the workspace files; `--update` refreshes only the generated ones. `doctor` checks profiles,
174
- `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the project rule table and the
175
- skill overrides. Full details in [install.md](./install.md).
179
+ `init` writes the workspace files; `--update` refreshes only the generated ones, and `--force` rewrites every file
180
+ 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`.
182
+ `doctor` checks profiles, `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the
183
+ project rule table and the skill overrides.
184
+
185
+ `reset` removes `node_modules` and the files `init` writes, for a clean reinstall. It deletes a once-only file only
186
+ when it is still identical to its template, so edited files stay; `cases/`, specs, baselines, `profiles.yaml`,
187
+ `package.json` and `.claude/skills/**` are never removed. `--state` also removes `.auth/`, `reports/`, `.tmp/`,
188
+ `.explore/`, `test-results/`, `playwright-report/` and `.playwright-mcp/`; `--lockfile` also removes
189
+ `pnpm-lock.yaml`. It lists what it would remove and asks for confirmation (`--yes` skips it, `--dry-run` only
190
+ lists). After it, run `pnpm dlx @asrunjs/asrun init --install` and `pnpm go`.
191
+
192
+ Full details in [install.md](./install.md).