@asrunjs/asrun 0.0.0-stage → 0.5.2
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/commands/explore-case.md +18 -0
- package/.claude/commands/gen-spec.md +16 -0
- package/.claude/commands/heal-spec.md +26 -0
- package/.claude/commands/make-case.md +11 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +146 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -0
- package/.claude/skills/exploring-cases/references/readiness.md +146 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
- package/.claude/skills/generating-specs/SKILL.md +219 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +72 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +39 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
- package/AGENTS.md +159 -0
- package/CLAUDE.md +2 -0
- package/LICENSE +21 -0
- package/README.md +511 -2
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +16 -0
- package/dist/lib/cases.js +51 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +15 -0
- package/dist/lib/node-cli.js +36 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +62 -0
- package/dist/lib/profiles.js +174 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +38 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +30 -0
- package/dist/lib/session.js +111 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +19 -0
- package/dist/lib/targets.js +26 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +21 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +149 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +296 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +75 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +22 -0
- package/dist/scripts/init.js +332 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +141 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +75 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +95 -0
- package/dist/ui/server/actions.js +365 -0
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +66 -0
- package/dist/ui/server/ai-stream.js +408 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +395 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +80 -0
- package/dist/ui/server/jobs.js +317 -0
- package/dist/ui/server/profiles.d.ts +35 -0
- package/dist/ui/server/profiles.js +371 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +88 -0
- package/dist/ui/shared/ai-commands.js +177 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +371 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -0
- package/package.json +109 -3
- package/templates/.claude/commands/explore-case.md +22 -0
- package/templates/.claude/commands/gen-spec.md +21 -0
- package/templates/.claude/commands/heal-spec.md +31 -0
- package/templates/.claude/commands/make-case.md +13 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +64 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +57 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +16 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +42 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
- package/ui/dist/assets/dist-Ba-HC662.js +2 -0
- package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
- package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
- package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-CmpteMYY.js +9 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-jjxQutEm.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
4
|
+
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
|
+
import { spawnTarget } from '../lib/command.js';
|
|
6
|
+
import { GENERATED, frameworkPath, frameworkVersion, renderTemplate } from './init.js';
|
|
7
|
+
import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
|
|
8
|
+
/**
|
|
9
|
+
* Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
|
|
10
|
+
* Errors exit with 1; warnings do not.
|
|
11
|
+
*/
|
|
12
|
+
const SKILLS = ['making-cases', 'exploring-cases', 'generating-specs'];
|
|
13
|
+
function read(file) {
|
|
14
|
+
try {
|
|
15
|
+
return fs.readFileSync(file, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The ids of the framework rules that a project may not narrow, parsed from the framework's AGENTS.md. */
|
|
22
|
+
function nonNegotiableRules() {
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
25
|
+
const match = /^- \*\*(F\d+)\b/.exec(line.trim());
|
|
26
|
+
if (match && line.includes('[non-negotiable]'))
|
|
27
|
+
ids.add(match[1]);
|
|
28
|
+
}
|
|
29
|
+
return ids;
|
|
30
|
+
}
|
|
31
|
+
/** Every rule id the framework baseline defines (`F1`..`F10`), so a project cannot name one that does not exist. */
|
|
32
|
+
function frameworkRuleIds() {
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
35
|
+
const match = /^- \*\*(F\d+)\b/.exec(line.trim());
|
|
36
|
+
if (match)
|
|
37
|
+
ids.add(match[1]);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The framework sources that `pnpm build` compiles into `dist/` and are older than it, or missing from it. Only the
|
|
43
|
+
* framework repository has those sources; an installed package ships `dist/` alone, so the list is empty there.
|
|
44
|
+
*/
|
|
45
|
+
function staleDistFiles() {
|
|
46
|
+
const stale = [];
|
|
47
|
+
const compare = (source) => {
|
|
48
|
+
const compiled = path.join(FRAMEWORK_ROOT, builtFile(source));
|
|
49
|
+
if (!fs.existsSync(compiled))
|
|
50
|
+
stale.push(`${builtFile(source)} is missing`);
|
|
51
|
+
else if (fs.statSync(path.join(FRAMEWORK_ROOT, source)).mtimeMs > fs.statSync(compiled).mtimeMs)
|
|
52
|
+
stale.push(source);
|
|
53
|
+
};
|
|
54
|
+
const walk = (dir) => {
|
|
55
|
+
if (!fs.existsSync(path.join(FRAMEWORK_ROOT, dir)))
|
|
56
|
+
return;
|
|
57
|
+
for (const entry of fs.readdirSync(path.join(FRAMEWORK_ROOT, dir), { withFileTypes: true })) {
|
|
58
|
+
const source = `${dir}/${entry.name}`;
|
|
59
|
+
if (entry.isDirectory())
|
|
60
|
+
walk(source);
|
|
61
|
+
// The plain `.mjs` tools are copied as they are; every other file is TypeScript and becomes JavaScript.
|
|
62
|
+
else if (source.endsWith('.ts') || COPIED_SCRIPTS.includes(entry.name))
|
|
63
|
+
compare(source);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk('lib');
|
|
67
|
+
walk('scripts');
|
|
68
|
+
return stale;
|
|
69
|
+
}
|
|
70
|
+
export async function main(args) {
|
|
71
|
+
if (args.includes('--help')) {
|
|
72
|
+
console.log('Usage: pnpm asrun doctor');
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
const errors = [];
|
|
76
|
+
const warnings = [];
|
|
77
|
+
const facts = [];
|
|
78
|
+
if (path.resolve(ROOT) === path.resolve(FRAMEWORK_ROOT)) {
|
|
79
|
+
console.error('No workspace found: this is the framework folder. Run the command in the project that uses the framework, ' +
|
|
80
|
+
'or set ASRUN_ROOT (the framework develops against examples/demo).');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
facts.push(`workspace: ${ROOT}`);
|
|
84
|
+
const actualPath = frameworkPath(ROOT);
|
|
85
|
+
facts.push(`framework: ${path.resolve(ROOT, actualPath)}`);
|
|
86
|
+
// The manifest, written by init: it names where the workspace reaches the framework.
|
|
87
|
+
const manifest = read(path.join(ROOT, '.asrun.json'));
|
|
88
|
+
let recordedPath;
|
|
89
|
+
const installedVersion = frameworkVersion();
|
|
90
|
+
if (manifest === null) {
|
|
91
|
+
warnings.push('No .asrun.json: run "pnpm asrun init" in this folder.');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(manifest);
|
|
96
|
+
recordedPath = parsed.frameworkPath;
|
|
97
|
+
facts.push(`version: asrun ${parsed.version ?? '?'} (${parsed.commit ?? '?'})`);
|
|
98
|
+
if (parsed.version !== undefined && parsed.version !== installedVersion.version) {
|
|
99
|
+
warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: run "pnpm asrun init --update".`);
|
|
100
|
+
}
|
|
101
|
+
if (recordedPath !== undefined && !fs.existsSync(path.resolve(ROOT, recordedPath))) {
|
|
102
|
+
errors.push(`.asrun.json points at "${recordedPath}", which does not exist. Run "pnpm add -D @asrunjs/asrun" and then "pnpm asrun init".`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
errors.push('.asrun.json is not valid JSON: delete it and run "pnpm asrun init" again.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const framework = recordedPath ?? actualPath;
|
|
110
|
+
// The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
|
|
111
|
+
// load the config from there. A build older than the sources therefore keeps runs on the previous code.
|
|
112
|
+
const stale = staleDistFiles();
|
|
113
|
+
if (stale.length > 0) {
|
|
114
|
+
const shown = stale.slice(0, 3).join(', ');
|
|
115
|
+
warnings.push(`dist/ is out of date (${shown}${stale.length > 3 ? `, and ${stale.length - 3} more` : ''}): run "pnpm build". ` +
|
|
116
|
+
'Workspaces load playwright.config.ts from dist, so they keep the previous configuration until then.');
|
|
117
|
+
}
|
|
118
|
+
// Profiles: they are the only source of environments, and a broken file stops every command.
|
|
119
|
+
try {
|
|
120
|
+
const profiles = loadProfiles();
|
|
121
|
+
if (profiles.length === 0) {
|
|
122
|
+
errors.push(`${PROFILES_FILE} has no profiles.`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
facts.push(`profiles: ${profiles.length} (${listEnvs(profiles).join(', ')})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
// How the specs reach the framework: through the package name (`@asrunjs/asrun/fixtures`), so only the file must exist.
|
|
132
|
+
if (read(path.join(ROOT, 'tsconfig.json')) === null) {
|
|
133
|
+
errors.push('tsconfig.json is missing: run "pnpm asrun init".');
|
|
134
|
+
}
|
|
135
|
+
const config = read(path.join(ROOT, 'playwright.config.ts'));
|
|
136
|
+
if (config === null) {
|
|
137
|
+
errors.push('playwright.config.ts is missing: run "pnpm asrun init".');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const target = /from\s+['"]([^'"]+)['"]/.exec(config)?.[1];
|
|
141
|
+
if (!target) {
|
|
142
|
+
errors.push('playwright.config.ts does not re-export the framework config (see templates/playwright.config.ts).');
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const resolved = path.resolve(ROOT, target);
|
|
146
|
+
if (!fs.existsSync(resolved) && !fs.existsSync(`${resolved}.ts`)) {
|
|
147
|
+
errors.push(`playwright.config.ts points at "${target}", which does not exist. Run "pnpm asrun init --force playwright.config.ts".`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const gitignore = read(path.join(ROOT, '.gitignore')) ?? '';
|
|
152
|
+
// A bare "*" ignores everything (a folder that only commits selected files); nothing can leak there.
|
|
153
|
+
const ignoresEverything = gitignore.split('\n').some((line) => line.trim() === '*');
|
|
154
|
+
for (const entry of ['.auth/', 'reports/']) {
|
|
155
|
+
if (!ignoresEverything && !gitignore.includes(entry)) {
|
|
156
|
+
errors.push(`.gitignore does not ignore "${entry}". Saved logins must never be committed.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Files generated from the templates: missing or edited ones are refreshed with `init --update`.
|
|
160
|
+
for (const entry of GENERATED) {
|
|
161
|
+
const current = read(path.join(ROOT, entry.file));
|
|
162
|
+
const template = renderTemplate(entry.template, ROOT);
|
|
163
|
+
if (current === null)
|
|
164
|
+
warnings.push(`${entry.file} is missing: run "pnpm asrun init".`);
|
|
165
|
+
else if (current !== template) {
|
|
166
|
+
warnings.push(`${entry.file} differs from the framework template: run "pnpm asrun init --update".`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Skills: the framework's, plus any project override.
|
|
170
|
+
const missingSkills = SKILLS.filter((skill) => !fs.existsSync(path.join(SKILLS_DIR, skill, 'SKILL.md')));
|
|
171
|
+
if (missingSkills.length > 0)
|
|
172
|
+
errors.push(`The framework folder is incomplete: no skill ${missingSkills.join(', ')}.`);
|
|
173
|
+
const skillsDir = path.join(ROOT, '.claude', 'skills');
|
|
174
|
+
const overrides = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((name) => name.endsWith('.md')) : [];
|
|
175
|
+
facts.push(`skills: ${SKILLS.join(', ')}${overrides.length > 0 ? ` (project overrides: ${overrides.map((name) => name.replace(/\.md$/, '')).join(', ')})` : ''}`);
|
|
176
|
+
for (const name of overrides) {
|
|
177
|
+
const text = read(path.join(skillsDir, name)) ?? '';
|
|
178
|
+
// An override refines a skill's procedure; redefining a safety rule is never an override.
|
|
179
|
+
if (/^\s*[-*]?\s*\*{0,2}F[1-6]\b/m.test(text)) {
|
|
180
|
+
errors.push(`.claude/skills/${name} redefines a safety rule (F1–F6); project rules never change those.`);
|
|
181
|
+
}
|
|
182
|
+
const frameworkSkill = read(path.join(SKILLS_DIR, name.replace(/\.md$/, ''), 'SKILL.md'));
|
|
183
|
+
if (frameworkSkill !== null && text.trim() === frameworkSkill.trim()) {
|
|
184
|
+
warnings.push(`.claude/skills/${name} is a copy of the framework skill; delete it, or it will never see an update.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Project rules: a project may narrow or replace a baseline rule, never one of F1–F6. Rows are
|
|
188
|
+
// `| <id> | <the project's rule> | <narrows|replaces> |`.
|
|
189
|
+
const agents = read(path.join(ROOT, 'AGENTS.md'));
|
|
190
|
+
if (agents === null) {
|
|
191
|
+
warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
if (!agents.includes(`${framework}/AGENTS.md`)) {
|
|
195
|
+
errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
|
|
196
|
+
}
|
|
197
|
+
const locked = nonNegotiableRules();
|
|
198
|
+
const known = frameworkRuleIds();
|
|
199
|
+
let fenced = false;
|
|
200
|
+
for (const line of agents.split('\n')) {
|
|
201
|
+
// A fenced block is documentation (an example table), not the project's rules.
|
|
202
|
+
if (line.trim().startsWith('```')) {
|
|
203
|
+
fenced = !fenced;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (fenced)
|
|
207
|
+
continue;
|
|
208
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
209
|
+
if (!/^F\d+$/.test(cells[0]))
|
|
210
|
+
continue;
|
|
211
|
+
const id = cells[0];
|
|
212
|
+
const effect = (cells[2] ?? '').toLowerCase();
|
|
213
|
+
if (!known.has(id))
|
|
214
|
+
errors.push(`AGENTS.md names ${id}, which is not a rule of the framework baseline.`);
|
|
215
|
+
else if (locked.has(id))
|
|
216
|
+
errors.push(`AGENTS.md changes ${id}, which the framework marks [non-negotiable].`);
|
|
217
|
+
else if (effect === '')
|
|
218
|
+
warnings.push(`AGENTS.md rule ${id} has no Effect column: add "narrows" or "replaces" (a table written before 0.3.0 predates it).`);
|
|
219
|
+
else if (effect !== 'narrows' && effect !== 'replaces')
|
|
220
|
+
errors.push(`AGENTS.md rule ${id} has Effect "${cells[2]}"; use "narrows" or "replaces".`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// The install and the commands the docs use.
|
|
224
|
+
const workspaceFile = read(path.join(ROOT, 'pnpm-workspace.yaml'));
|
|
225
|
+
if (framework.startsWith('..')) {
|
|
226
|
+
// The framework is outside the workspace (examples/demo): the framework repository manages itself.
|
|
227
|
+
}
|
|
228
|
+
else if (workspaceFile === null) {
|
|
229
|
+
warnings.push('pnpm-workspace.yaml is missing: add the "allowBuilds" block from templates/pnpm-workspace.yaml, or pnpm skips the build scripts of esbuild and ffmpeg-static.');
|
|
230
|
+
}
|
|
231
|
+
else if (!workspaceFile.includes('allowBuilds')) {
|
|
232
|
+
warnings.push('pnpm-workspace.yaml has no "allowBuilds": pnpm skips the build scripts of esbuild and ffmpeg-static.');
|
|
233
|
+
}
|
|
234
|
+
else if (/^packages\s*:/m.test(workspaceFile)) {
|
|
235
|
+
warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
|
|
236
|
+
}
|
|
237
|
+
let scripts = null;
|
|
238
|
+
try {
|
|
239
|
+
const packageJson = read(path.join(ROOT, 'package.json'));
|
|
240
|
+
if (packageJson !== null)
|
|
241
|
+
scripts = JSON.parse(packageJson).scripts ?? {};
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
warnings.push('package.json is not valid JSON.');
|
|
245
|
+
}
|
|
246
|
+
for (const name of ['asrun', 'signin', 'ui']) {
|
|
247
|
+
if (scripts !== null && !scripts[name])
|
|
248
|
+
warnings.push(`package.json has no "${name}" script: run "pnpm asrun init".`);
|
|
249
|
+
}
|
|
250
|
+
// The agent CLIs the AI commands start. Only those commands need one, so a missing one is a warning, not an error.
|
|
251
|
+
// Checked the way the web UI starts them, so a Windows `.cmd` shim is reported instead of failing later.
|
|
252
|
+
const tools = ['claude', 'opencode']
|
|
253
|
+
.map((name) => ({ name, target: spawnTarget(name) }))
|
|
254
|
+
.filter((tool) => tool.target !== null);
|
|
255
|
+
if (tools.length === 0) {
|
|
256
|
+
warnings.push('Neither "claude" (Claude Code) nor "opencode" was found on PATH: the AI commands (make cases, explore a case, generate a spec) cannot start.');
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
facts.push(`ai tools: ${tools
|
|
260
|
+
.map((tool) => `${tool.name} -> ${tool.target.path}${tool.target.throughShell ? ' (through cmd.exe)' : ''}`)
|
|
261
|
+
.join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
// The MCP servers the AI commands explore with: OpenCode reads only `opencode.jsonc`, Claude Code only `.mcp.json`.
|
|
264
|
+
for (const [file, tool] of [
|
|
265
|
+
['.mcp.json', 'Claude Code'],
|
|
266
|
+
['opencode.jsonc', 'OpenCode'],
|
|
267
|
+
]) {
|
|
268
|
+
const text = read(path.join(ROOT, file));
|
|
269
|
+
if (text === null) {
|
|
270
|
+
warnings.push(`${file} is missing: ${tool} has no "playwright-test" MCP server. Run "pnpm asrun init".`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!text.includes('playwright-test')) {
|
|
274
|
+
warnings.push(`${file} does not define the "playwright-test" MCP server: run "pnpm asrun init --force ${file}".`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const command = /"command"\s*:\s*(?:\[\s*)?"([^"]*playwright[^"]*)"/.exec(text)?.[1];
|
|
278
|
+
if (command !== undefined && !fs.existsSync(path.resolve(ROOT, command)) && !fs.existsSync(path.resolve(ROOT, `${command}.cmd`))) {
|
|
279
|
+
warnings.push(`${file} starts the MCP server with "${command}", which does not exist: run "pnpm asrun init --force ${file}".`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const settings = read(path.join(ROOT, '.claude', 'settings.json'));
|
|
283
|
+
if (settings === null) {
|
|
284
|
+
warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
|
|
285
|
+
}
|
|
286
|
+
else if (!settings.includes('pnpm asrun')) {
|
|
287
|
+
warnings.push('.claude/settings.json does not exclude "pnpm asrun" from the sandbox: the browser cannot start in it.');
|
|
288
|
+
}
|
|
289
|
+
console.log(facts.join('\n'));
|
|
290
|
+
for (const warning of warnings)
|
|
291
|
+
console.log(`\n! ${warning}`);
|
|
292
|
+
for (const error of errors)
|
|
293
|
+
console.log(`\nx ${error}`);
|
|
294
|
+
console.log(`\nDoctor: ${errors.length} error(s), ${warnings.length} warning(s).`);
|
|
295
|
+
return errors.length > 0 ? 1 : 0;
|
|
296
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chromium } from '@playwright/test';
|
|
2
|
+
import { getProfile, listProfiles, profileLabel } from '../lib/profiles.js';
|
|
3
|
+
import { parseCliTarget } from '../lib/cli.js';
|
|
4
|
+
import { authFile } from '../lib/paths.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const parsed = parseCliTarget(process.argv.slice(2));
|
|
9
|
+
const pathname = parsed.rest[0] ?? '/';
|
|
10
|
+
if (!parsed.profileId || parsed.help) {
|
|
11
|
+
console.log('Usage: pnpm explore <profile-id> [path] (opens a logged-in browser to look around)\n\n' +
|
|
12
|
+
'Profiles (the environment is part of each profile):\n' +
|
|
13
|
+
listProfiles()
|
|
14
|
+
.map((profile) => ` ${profile.id.padEnd(24)} ${profile.env.padEnd(10)} ${profile.baseURL}`)
|
|
15
|
+
.join('\n'));
|
|
16
|
+
process.exit(parsed.help ? 0 : 1);
|
|
17
|
+
}
|
|
18
|
+
if (parsed.envGiven) {
|
|
19
|
+
console.error('explore takes only the profile: its environment is part of profiles.yaml. Drop --env.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const profile = getProfile(parsed.profileId);
|
|
23
|
+
const file = authFile(profile.id);
|
|
24
|
+
const browser = await chromium.launch({ headless: false });
|
|
25
|
+
const context = await browser.newContext({
|
|
26
|
+
viewport: null,
|
|
27
|
+
...(fs.existsSync(file) && { storageState: file }),
|
|
28
|
+
});
|
|
29
|
+
// Playwright keeps downloads in a temporary folder and deletes it on close, so save each file to ~/Downloads.
|
|
30
|
+
const downloadsDir = path.join(os.homedir(), 'Downloads');
|
|
31
|
+
const freeName = (name) => {
|
|
32
|
+
const { name: base, ext } = path.parse(path.basename(name) || 'download');
|
|
33
|
+
let candidate = path.join(downloadsDir, `${base}${ext}`);
|
|
34
|
+
for (let n = 1; fs.existsSync(candidate); n++)
|
|
35
|
+
candidate = path.join(downloadsDir, `${base} (${n})${ext}`);
|
|
36
|
+
return candidate;
|
|
37
|
+
};
|
|
38
|
+
const saveDownload = async (download) => {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(downloadsDir, { recursive: true });
|
|
41
|
+
const target = freeName(download.suggestedFilename());
|
|
42
|
+
await download.saveAs(target);
|
|
43
|
+
console.log(`Saved download: ${target}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`Could not save download "${download.suggestedFilename()}": ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const watchPage = (page) => page.on('download', (download) => void saveDownload(download));
|
|
50
|
+
context.on('page', watchPage);
|
|
51
|
+
const page = await context.newPage();
|
|
52
|
+
await page.goto(new URL(pathname, profile.baseURL).href);
|
|
53
|
+
console.log(`Opened ${profileLabel(profile)}. Close the browser window to exit.`);
|
|
54
|
+
// Chromium keeps its process alive after the last window is closed, so waiting for `disconnected` alone
|
|
55
|
+
// never finishes. Closing the last window does close its pages, and that is the signal to stop.
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const done = () => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
resolve();
|
|
63
|
+
};
|
|
64
|
+
browser.on('disconnected', done);
|
|
65
|
+
context.on('close', done);
|
|
66
|
+
const watch = (open) => open.on('close', () => {
|
|
67
|
+
if (context.pages().length === 0)
|
|
68
|
+
done();
|
|
69
|
+
});
|
|
70
|
+
context.pages().forEach(watch);
|
|
71
|
+
context.on('page', watch);
|
|
72
|
+
});
|
|
73
|
+
// The window is gone, but its browser process can still be running: end it so no orphan process is left.
|
|
74
|
+
await browser.close().catch(() => { });
|
|
75
|
+
console.log('Browser closed.');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// One-liner for a fresh machine: install everything, prepare the workspace, then start the web UI and open it.
|
|
2
|
+
// Plain Node on purpose: it may run before the dependencies are installed.
|
|
3
|
+
//
|
|
4
|
+
// pnpm add -D @asrunjs/asrun (or github:wizcas/asrun#v0.1.0)
|
|
5
|
+
// pnpm exec asrun init
|
|
6
|
+
// pnpm go
|
|
7
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const FRAMEWORK = path.resolve(import.meta.dirname, '..');
|
|
12
|
+
const PACKAGE = '@asrunjs/asrun';
|
|
13
|
+
const MARKERS = ['.asrun.json', 'profiles.yaml'];
|
|
14
|
+
const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
|
|
15
|
+
const url = `http://127.0.0.1:${port}`;
|
|
16
|
+
const openBrowser = !process.argv.includes('--no-open');
|
|
17
|
+
const shell = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
// `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
|
|
20
|
+
function loadRootEnv(file) {
|
|
21
|
+
try {
|
|
22
|
+
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
23
|
+
if (process.env.ASRUN_ROOT !== undefined) return;
|
|
24
|
+
const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
|
|
25
|
+
if (match) {
|
|
26
|
+
process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// no .env: nothing to do
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
loadRootEnv(path.join(process.cwd(), '.env'));
|
|
34
|
+
if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
|
|
35
|
+
|
|
36
|
+
// The workspace: the project that owns profiles.yaml, cases/ and tests/. Inside the framework repository
|
|
37
|
+
// (which has none of those) the demo workspace is used, so the framework itself can be developed this way.
|
|
38
|
+
function findWorkspace(from) {
|
|
39
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
40
|
+
if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
|
|
41
|
+
if (path.dirname(dir) === dir) return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const demo = path.join(FRAMEWORK, 'examples', 'demo');
|
|
45
|
+
const ROOT = (process.env.ASRUN_ROOT && path.resolve(process.env.ASRUN_ROOT)) || findWorkspace(process.cwd()) || demo;
|
|
46
|
+
if (ROOT === FRAMEWORK && !fs.existsSync(demo)) {
|
|
47
|
+
console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
|
|
52
|
+
const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
|
|
53
|
+
const fail = (text) => {
|
|
54
|
+
console.error(`\x1b[31m${text}\x1b[0m`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
};
|
|
57
|
+
const run = (command, args, options = {}) =>
|
|
58
|
+
spawnSync(command, args, {cwd: ROOT, stdio: 'inherit', shell, ...options}).status === 0;
|
|
59
|
+
const exists = (command) => spawnSync(command, ['--version'], {stdio: 'ignore', shell}).status === 0;
|
|
60
|
+
|
|
61
|
+
/** The JavaScript entry point of a package's command-line tool, without relying on node_modules/.bin shims. */
|
|
62
|
+
function packageBin(name, command = name) {
|
|
63
|
+
for (const base of [FRAMEWORK, path.dirname(path.dirname(FRAMEWORK)), ROOT]) {
|
|
64
|
+
const dir = path.join(base, 'node_modules', name);
|
|
65
|
+
const manifest = path.join(dir, 'package.json');
|
|
66
|
+
if (!fs.existsSync(manifest)) continue;
|
|
67
|
+
const bin = JSON.parse(fs.readFileSync(manifest, 'utf8')).bin;
|
|
68
|
+
const entry = typeof bin === 'string' ? bin : bin?.[command];
|
|
69
|
+
if (entry) return path.join(dir, entry);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The installed command of this workspace, or the framework's own bin while developing it here. */
|
|
75
|
+
const bin = packageBin(PACKAGE) ?? path.join(FRAMEWORK, 'bin', 'asrun.mjs');
|
|
76
|
+
/** The workspace's copy of the package, if any. */
|
|
77
|
+
const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
|
|
78
|
+
const frameworkRelative = installed
|
|
79
|
+
? './' + path.relative(ROOT, path.join(ROOT, 'node_modules', PACKAGE)).split(path.sep).join('/')
|
|
80
|
+
: (() => {
|
|
81
|
+
const relative = path.relative(ROOT, FRAMEWORK).split(path.sep).join('/');
|
|
82
|
+
return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
/** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
|
|
86
|
+
function ensurePnpmWorkspace() {
|
|
87
|
+
if (frameworkRelative.startsWith('..')) return 'skipped';
|
|
88
|
+
const file = path.join(ROOT, 'pnpm-workspace.yaml');
|
|
89
|
+
const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
|
|
90
|
+
if (!fs.existsSync(file)) {
|
|
91
|
+
fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
|
|
92
|
+
return 'created';
|
|
93
|
+
}
|
|
94
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
95
|
+
if (text.includes('allowBuilds')) return 'kept';
|
|
96
|
+
fs.writeFileSync(file, `${text.replace(/\n*$/, '\n')}\n${block}`);
|
|
97
|
+
return 'updated';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function uiIsUp() {
|
|
101
|
+
try {
|
|
102
|
+
return (await fetch(`${url}/api/info`, {signal: AbortSignal.timeout(1500)})).ok;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function open(target) {
|
|
109
|
+
const [command, args] =
|
|
110
|
+
process.platform === 'darwin'
|
|
111
|
+
? ['open', [target]]
|
|
112
|
+
: process.platform === 'win32'
|
|
113
|
+
? ['cmd', ['/c', 'start', '', target]]
|
|
114
|
+
: ['xdg-open', [target]];
|
|
115
|
+
spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
|
|
119
|
+
function ensureFfmpeg() {
|
|
120
|
+
// ffmpeg does not know `--version`, only `-version`.
|
|
121
|
+
if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
|
|
122
|
+
const installer =
|
|
123
|
+
process.platform === 'darwin' && exists('brew')
|
|
124
|
+
? ['brew', ['install', 'ffmpeg']]
|
|
125
|
+
: process.platform === 'win32' && exists('winget')
|
|
126
|
+
? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
|
|
127
|
+
: null;
|
|
128
|
+
if (!installer) {
|
|
129
|
+
return warn('ffmpeg not found; the bundled ffmpeg-static is used instead. To install it yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
|
|
130
|
+
}
|
|
131
|
+
console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
|
|
132
|
+
if (run(...installer)) console.log('ffmpeg: installed');
|
|
133
|
+
else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
step(1, 'Checking this machine');
|
|
137
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
138
|
+
if (nodeMajor < 22) fail(`Node 22 or newer is required (this is ${process.versions.node}). Install it from https://nodejs.org and run "pnpm go" again.`);
|
|
139
|
+
console.log(`Node ${process.versions.node}: ok`);
|
|
140
|
+
console.log(`workspace: ${ROOT}`);
|
|
141
|
+
console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
|
|
142
|
+
ensureFfmpeg();
|
|
143
|
+
if (!exists('claude') && !exists('opencode')) {
|
|
144
|
+
warn('Neither "claude" (Claude Code) nor "opencode" found. Everything works except the AI steps (make cases, review, generate specs).');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
step(2, 'Installing dependencies and the browser (a minute the first time, seconds after that)');
|
|
148
|
+
const workspaceFile = ensurePnpmWorkspace();
|
|
149
|
+
if (workspaceFile === 'created') console.log('pnpm-workspace.yaml: created (allowBuilds)');
|
|
150
|
+
else if (workspaceFile === 'updated') console.log('pnpm-workspace.yaml: updated (allowBuilds)');
|
|
151
|
+
const installRoot = frameworkRelative.startsWith('..') ? FRAMEWORK : ROOT;
|
|
152
|
+
if (!run('pnpm', ['install'], {cwd: installRoot})) fail('Install failed. Fix the error above and run "pnpm go" again.');
|
|
153
|
+
const playwright = packageBin('@playwright/test', 'playwright');
|
|
154
|
+
if (playwright) {
|
|
155
|
+
if (!run(process.execPath, [playwright, 'install', 'chromium'])) warn('Could not install the Chromium browser; run "pnpm exec playwright install chromium".');
|
|
156
|
+
} else {
|
|
157
|
+
warn('@playwright/test is not installed yet. Run this command again after the workspace files are in place.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
step(3, 'Preparing the workspace');
|
|
161
|
+
// `--root` names the workspace, but the command runs where the person is: that is where `.env` belongs.
|
|
162
|
+
if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()})) {
|
|
163
|
+
fail('Could not initialize the workspace. Run "pnpm asrun init" and read the message.');
|
|
164
|
+
}
|
|
165
|
+
run(process.execPath, [bin, 'doctor']);
|
|
166
|
+
|
|
167
|
+
step(4, `Starting the web UI at ${url}`);
|
|
168
|
+
if (await uiIsUp()) {
|
|
169
|
+
console.log('The UI is already running.');
|
|
170
|
+
if (openBrowser) open(url);
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const env = {...process.env, ASRUN_ROOT: ROOT};
|
|
175
|
+
const child = spawn(process.execPath, [bin, 'ui'], {cwd: ROOT, stdio: 'inherit', env, shell});
|
|
176
|
+
// Ctrl+C reaches the UI server directly (same terminal); we only wait for it to shut down.
|
|
177
|
+
process.on('SIGINT', () => {});
|
|
178
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
179
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < 240 && child.exitCode === null; i++) {
|
|
182
|
+
if (await uiIsUp()) {
|
|
183
|
+
console.log(`\n\x1b[1;32mReady: ${url}\x1b[0m (Ctrl+C to stop)`);
|
|
184
|
+
if (openBrowser) open(url);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
188
|
+
}
|