@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.
- 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/go.mjs +36 -6
- package/dist/scripts/heal-evidence.mjs +149 -143
- package/dist/scripts/init.d.ts +7 -0
- package/dist/scripts/init.js +57 -15
- 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 +47 -10
- package/docs/troubleshooting.md +31 -10
- 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 -32
- 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,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,
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
238
|
-
// `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.
|
|
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
|
-
|
|
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
|
+
}
|
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).
|