@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.
- package/.claude/settings.json +5 -5
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -103
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -128
- package/.claude/skills/making-cases/references/gaps.md +36 -36
- package/.claude/skills/making-cases/references/splitting.md +28 -28
- package/AGENTS.md +3 -0
- package/README.md +16 -2
- package/bin/asrun.mjs +2 -1
- package/dist/scripts/cli.js +14 -6
- package/dist/scripts/doctor.js +7 -2
- package/dist/scripts/go.mjs +36 -6
- package/dist/scripts/heal-evidence.mjs +149 -143
- package/dist/scripts/init.d.ts +9 -1
- package/dist/scripts/init.js +101 -26
- package/dist/scripts/reset.d.ts +1 -0
- package/dist/scripts/reset.js +233 -0
- package/dist/scripts/run.js +23 -23
- package/docs/agents/framework-dev.md +29 -1
- package/docs/commands.md +25 -8
- package/docs/install.md +52 -10
- package/docs/troubleshooting.md +31 -4
- package/package.json +34 -30
- package/templates/.claude/settings.json +5 -5
- package/templates/CLAUDE.md +1 -1
- package/templates/gitignore +13 -13
- package/templates/package.json +33 -31
- package/templates/playwright.config.ts +3 -3
- package/templates/pnpm-workspace.yaml +8 -5
- package/templates/seed.spec.ts +9 -9
- package/templates/tsconfig.json +14 -14
- package/ui/dist/favicon.svg +11 -11
- package/ui/dist/logos/claude.svg +1 -1
- package/ui/dist/logos/opencode.svg +1 -1
package/dist/scripts/init.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
223
|
-
// `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/scripts/run.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
159
|
-
pnpm
|
|
160
|
-
pnpm ui
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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).
|