@asrunjs/asrun 0.6.0 → 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,18 +3,21 @@ 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)
17
- --install [spec] also add the dependency (default "asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
20
+ --install [spec] also add the dependency (default "@asrunjs/asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
18
21
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
19
22
  export const GENERATED = [
20
23
  { file: 'playwright.config.ts', template: 'playwright.config.ts' },
@@ -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,9 +47,9 @@ 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).';
49
- const PACKAGE = '@asrunjs/asrun';
50
+ export const MARKER = '.asrun.json';
51
+ export const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
52
+ export const PACKAGE = '@asrunjs/asrun';
50
53
  function readTemplate(name) {
51
54
  return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
52
55
  }
@@ -79,11 +82,18 @@ export function frameworkPath(root) {
79
82
  export function isInstalled(root) {
80
83
  return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
81
84
  }
82
- /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
85
+ /** The range the workspace's `package.json` names for the framework: `^` plus the version that wrote the workspace. */
86
+ let asrunRange;
87
+ function asrunDependencyRange() {
88
+ asrunRange ??= `^${frameworkVersion().version}`;
89
+ return asrunRange;
90
+ }
91
+ /** Fills the `{{framework}}`, `{{playwright}}` and `{{asrun}}` placeholders of a template. */
83
92
  export function renderTemplate(name, root) {
84
93
  return readTemplate(name)
85
94
  .replaceAll('{{framework}}', frameworkPath(root))
86
- .replaceAll('{{playwright}}', playwrightBin(root));
95
+ .replaceAll('{{playwright}}', playwrightBin(root))
96
+ .replaceAll('{{asrun}}', asrunDependencyRange());
87
97
  }
88
98
  /**
89
99
  * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
@@ -113,13 +123,21 @@ export function frameworkVersion() {
113
123
  }
114
124
  return { version, commit };
115
125
  }
116
- /** Merges the template's npm scripts and devDependencies into an existing package.json (never overwrites). */
126
+ /** True when any dependency block of the manifest already names `name`, whatever the range. */
127
+ function declaredIn(manifest, name) {
128
+ return [manifest.dependencies, manifest.devDependencies, manifest.optionalDependencies, manifest.peerDependencies].some((block) => block?.[name] !== undefined);
129
+ }
130
+ /**
131
+ * Merges the template's npm scripts and devDependencies into an existing package.json (never overwrites). The
132
+ * framework itself is one of those devDependencies, so a workspace whose `pnpm add` step failed still names it and
133
+ * `pnpm install` is enough to fix it.
134
+ */
117
135
  function mergePackageJson(root) {
118
136
  const file = path.join(root, 'package.json');
119
137
  if (!fs.existsSync(file))
120
138
  return [];
121
139
  const current = JSON.parse(fs.readFileSync(file, 'utf8'));
122
- const template = JSON.parse(readTemplate('package.json'));
140
+ const template = JSON.parse(renderTemplate('package.json', root));
123
141
  const added = [];
124
142
  current.scripts ??= {};
125
143
  for (const [name, command] of Object.entries(template.scripts)) {
@@ -130,10 +148,10 @@ function mergePackageJson(root) {
130
148
  }
131
149
  current.devDependencies ??= {};
132
150
  for (const [name, range] of Object.entries(template.devDependencies)) {
133
- if (current.devDependencies[name] === undefined) {
134
- current.devDependencies[name] = range;
135
- added.push(`devDependencies.${name}`);
136
- }
151
+ if (declaredIn(current, name))
152
+ continue;
153
+ current.devDependencies[name] = range;
154
+ added.push(`devDependencies.${name}`);
137
155
  }
138
156
  if (added.length > 0)
139
157
  fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
@@ -204,9 +222,22 @@ export async function main(args) {
204
222
  const installAt = args.indexOf('--install');
205
223
  const install = installAt >= 0;
206
224
  const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
207
- const forced = args
208
- .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
209
- .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
+ }
210
241
  const resetRules = args.includes('--reset-rules');
211
242
  const skipEnv = args.includes('--no-env');
212
243
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
@@ -219,8 +250,9 @@ export async function main(args) {
219
250
  const kept = [];
220
251
  const notes = [];
221
252
  const framework = frameworkPath(root);
222
- // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
223
- // `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.
224
256
  const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
225
257
  const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
226
258
  for (const file of forced) {
@@ -244,14 +276,26 @@ export async function main(args) {
244
276
  written.push(entry.file);
245
277
  };
246
278
  for (const entry of GENERATED)
247
- place(entry, update || forced.includes(entry.file));
279
+ place(entry, update || forceAll || forced.includes(entry.file));
280
+ const keptProjectData = [];
281
+ const keptRules = [];
248
282
  for (const entry of ONCE) {
249
283
  // The demo workspace lives inside the framework repository: it has no package manager settings of its own.
250
284
  if (framework.startsWith('..') && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))
251
285
  continue;
252
- 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);
253
297
  }
254
- const gitignore = ensureGitignore(root, update);
298
+ const gitignore = ensureGitignore(root, update || forceAll);
255
299
  const merged = mergePackageJson(root);
256
300
  const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
257
301
  if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
@@ -265,6 +309,10 @@ export async function main(args) {
265
309
  if (!workspace.includes('allowBuilds')) {
266
310
  notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
267
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
+ }
268
316
  if (/^packages\s*:/m.test(workspace)) {
269
317
  notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
270
318
  }
@@ -290,11 +338,38 @@ export async function main(args) {
290
338
  notes.push('Add "pnpm asrun *" to sandbox.excludedCommands in .claude/settings.json (see templates/.claude/settings.json).');
291
339
  }
292
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
+ }
293
350
  // Install last, so pnpm-workspace.yaml (the build-script approval) already exists. Its absence is the reason
294
351
  // "pnpm add -D @asrunjs/asrun" fails on pnpm 12 with ERR_PNPM_IGNORED_BUILDS.
295
352
  const installed = isInstalled(root);
296
353
  if (install && !installed) {
297
- if (spawnSync('pnpm', ['add', '-D', installSpec], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' }).status !== 0) {
354
+ const result = spawnSync('pnpm', ['add', '-D', installSpec], {
355
+ cwd: root,
356
+ stdio: 'inherit',
357
+ shell: process.platform === 'win32',
358
+ });
359
+ if (result.status !== 0) {
360
+ // `status` is null when pnpm never started (not on PATH, or killed): pnpm prints nothing then, so without this
361
+ // message the workspace would stay half initialized with no explanation.
362
+ const error = result.error;
363
+ const reason = error !== undefined
364
+ ? error.code === 'ENOENT'
365
+ ? 'pnpm was not found on PATH'
366
+ : error.message
367
+ : result.signal !== null
368
+ ? `pnpm was stopped by ${result.signal}`
369
+ : `pnpm add exited with code ${result.status}`;
370
+ console.error(`\n ! ${installSpec} was not installed: ${reason}`);
371
+ console.error(` Run "pnpm add -D ${installSpec}" in ${root}, then "pnpm asrun init" again.`);
372
+ console.error(' The files above are written, but nothing that calls "asrun" works until the dependency is there.');
298
373
  return 1;
299
374
  }
300
375
  }
@@ -307,7 +382,7 @@ export async function main(args) {
307
382
  generated: GENERATED.map((entry) => entry.file),
308
383
  updatedAt: new Date().toISOString(),
309
384
  }, null, 2) + '\n');
310
- const label = firstRun ? 'Initialized' : update ? 'Refreshed' : 'Checked';
385
+ const label = firstRun ? 'Initialized' : forceAll ? 'Forced' : update ? 'Refreshed' : 'Checked';
311
386
  console.log(`${label} workspace: ${root}`);
312
387
  console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
313
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).