@elalfner/relevo 0.5.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-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/dev-server-guard.mjs +32 -0
- package/scripts/extra-server.mjs +78 -0
- package/scripts/go-worktree.mjs +40 -0
- package/scripts/lib/dev-server-guard.mjs +49 -0
- package/scripts/lib/doctor.mjs +14 -1
- package/scripts/lib/extra-server.mjs +84 -0
- package/scripts/lib/go-local.mjs +4 -3
- package/scripts/lib/go-worktree.mjs +85 -0
- package/scripts/lib/init.mjs +2 -2
- package/scripts/lib/review-adapters.mjs +15 -2
- package/scripts/lib/review-brief.mjs +12 -4
- package/scripts/lib/review-state.mjs +30 -4
- package/scripts/lib/web-preflight.mjs +87 -0
- package/scripts/lib/web-review.mjs +82 -1
- package/scripts/lib/worktree-path.mjs +11 -0
- package/scripts/review-adapter.mjs +5 -3
- package/scripts/review-brief.mjs +3 -2
- package/scripts/review-state.mjs +12 -5
- package/scripts/web-preflight.mjs +47 -0
- package/scripts/web-review.mjs +80 -29
- package/skills/go/SKILL.md +13 -1
- package/skills/init/SKILL.md +2 -2
- package/skills/review/SKILL.md +48 -10
- package/stacks/flutter.md +45 -7
- package/templates/workflows/relevo-digest.yml +1 -1
- package/templates/workflows/relevo-go.yml +1 -1
- package/templates/workflows/relevo-queue.yml +1 -1
- package/templates/workflows/relevo-review.yml +1 -1
- package/templates/workflows/relevo-sla.yml +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// What a browser review checks before it drives: the project's dependencies, Playwright and its browser, and the
|
|
2
|
+
// address under review with the module scripts its page loads. Everything that touches the machine is injected.
|
|
3
|
+
// Implements "A browser review checks its tools and the app before driving" of openspec/specs/review/spec.md.
|
|
4
|
+
|
|
5
|
+
export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6 };
|
|
6
|
+
|
|
7
|
+
const START_SCRIPTS = ['dev', 'serve', 'start'];
|
|
8
|
+
|
|
9
|
+
/** The command that starts the app, from the scripts of package.json; null when there is none. */
|
|
10
|
+
export function startCommand(pkgText) {
|
|
11
|
+
let scripts = {};
|
|
12
|
+
try {
|
|
13
|
+
scripts = JSON.parse(pkgText ?? '{}').scripts ?? {};
|
|
14
|
+
} catch { /* an unreadable package.json names no command */ }
|
|
15
|
+
const name = START_SCRIPTS.find((s) => typeof scripts[s] === 'string');
|
|
16
|
+
return name ? `npm run ${name}` : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The variable names of an example environment file (`NAME=value` lines); values are ignored. */
|
|
20
|
+
export function envNames(text) {
|
|
21
|
+
return [...String(text ?? '').matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The same-origin module scripts that an HTML page loads, as absolute URLs. */
|
|
25
|
+
export function moduleScripts(html, pageUrl) {
|
|
26
|
+
const origin = new URL(pageUrl).origin;
|
|
27
|
+
return [...String(html ?? '').matchAll(/<script\b[^>]*>/gi)]
|
|
28
|
+
.map((m) => m[0])
|
|
29
|
+
.filter((tag) => /\btype\s*=\s*["']?module\b/i.test(tag))
|
|
30
|
+
.map((tag) => tag.match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1])
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((src) => new URL(src, pageUrl))
|
|
33
|
+
.filter((u) => u.origin === origin)
|
|
34
|
+
.map((u) => u.href);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Does the page answer below 400 after redirects, and so do its module scripts? Returns { ok, detail }. */
|
|
38
|
+
export async function checkUrl(url, { fetch, timeout = 5000 }) {
|
|
39
|
+
const get = async (address) => {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(address, { redirect: 'follow', signal: AbortSignal.timeout(timeout) });
|
|
42
|
+
return { status: response.status, text: response.status < 400 ? await response.text() : '' };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { status: null, error: String(error?.cause?.code ?? error?.name ?? error?.message ?? error) };
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const page = await get(url);
|
|
48
|
+
if (page.status === null) return { ok: false, detail: `${url} does not answer (${page.error})` };
|
|
49
|
+
if (page.status >= 400) return { ok: false, detail: `${url} answered ${page.status}` };
|
|
50
|
+
for (const script of moduleScripts(page.text, url)) {
|
|
51
|
+
const r = await get(script);
|
|
52
|
+
if (r.status === null) return { ok: false, detail: `${script} does not answer (${r.error})` };
|
|
53
|
+
if (r.status >= 400) return { ok: false, detail: `${script} answered ${r.status}` };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, detail: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, code }.
|
|
60
|
+
* deps: exists(path), readFile(path) (null when missing), loadPlaywright(), fetch, sleep(ms), now().
|
|
61
|
+
* Only `.env.example` is read for variable names; the real environment files never are.
|
|
62
|
+
* With `wait`, the address is retried every 500 ms until it answers or the time is over.
|
|
63
|
+
*/
|
|
64
|
+
export async function webPreflight({ cwd, url, timeout = 5000, wait = 0 }, deps) {
|
|
65
|
+
const { exists, readFile, loadPlaywright, fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = deps;
|
|
66
|
+
const at = (name) => `${cwd}/${name}`;
|
|
67
|
+
const start = startCommand(readFile(at('package.json')));
|
|
68
|
+
const env = envNames(readFile(at('.env.example')));
|
|
69
|
+
const result = (check, detail) => ({ ok: check === null, check, detail, start, env, code: check === null ? 0 : EXIT[check] });
|
|
70
|
+
|
|
71
|
+
if (!exists(at('node_modules'))) return result('node_modules', 'the project has no node_modules: run npm install first');
|
|
72
|
+
const playwright = loadPlaywright();
|
|
73
|
+
if (!playwright) return result('playwright', 'Playwright does not resolve from the project');
|
|
74
|
+
let executable = null;
|
|
75
|
+
try {
|
|
76
|
+
executable = playwright.chromium.executablePath();
|
|
77
|
+
} catch { /* an unknown browser build has no path */ }
|
|
78
|
+
if (!executable || !exists(executable)) return result('browser', 'the Chromium of Playwright is not installed');
|
|
79
|
+
|
|
80
|
+
const deadline = now() + wait;
|
|
81
|
+
for (;;) {
|
|
82
|
+
const answer = await checkUrl(url, { fetch, timeout });
|
|
83
|
+
if (answer.ok) return result(null, null);
|
|
84
|
+
if (now() >= deadline) return result('url', answer.detail);
|
|
85
|
+
await sleep(500);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
// The rules of a driven web review that must not vary: which origins a page may reach, what a review script may
|
|
2
2
|
// contain, how a secret is kept out of the output, and the log the audit reads.
|
|
3
3
|
// Implements "The agent operates the app only within fixed limits", "A web review script is reviewed before it runs",
|
|
4
|
-
// "Credentials of a driven review are names, not values"
|
|
4
|
+
// "Credentials of a driven review are names, not values", "The audit understands a browser",
|
|
5
|
+
// "A browser review takes captures through the runner" and "A failing page is reported as such" of openspec/specs/review/spec.md.
|
|
6
|
+
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
9
|
+
import { safeCaptureName } from './device.mjs';
|
|
5
10
|
|
|
6
11
|
export const WEB_PACKAGE = 'web';
|
|
7
12
|
|
|
13
|
+
/** Playwright as the project resolves it, else as the plugin does; null when neither has it. */
|
|
14
|
+
export function loadPlaywright(cwd = process.cwd()) {
|
|
15
|
+
for (const base of [join(cwd, 'package.json'), import.meta.url]) {
|
|
16
|
+
try {
|
|
17
|
+
return createRequire(base)('playwright');
|
|
18
|
+
} catch { /* try the next place */ }
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
9
24
|
// URLs that do not reach the network.
|
|
10
25
|
const OFFLINE_PROTOCOLS = new Set(['data:', 'blob:', 'about:']);
|
|
@@ -41,6 +56,15 @@ const FORBIDDEN = [
|
|
|
41
56
|
[/\bglobalThis\s*\.\s*process\b/, 'it reaches the process'],
|
|
42
57
|
[/\bfetch\s*\(/, 'it makes a request outside the browser with fetch'],
|
|
43
58
|
[/\brequire\.resolve\b|\bimport\.meta\b/, 'it inspects the module system'],
|
|
59
|
+
[/\bscreenshot\s*\(|\[\s*['"`]screenshot['"`]\s*\]/, 'it takes a screenshot itself; take it with capture(name, check), which saves it in the output folder'],
|
|
60
|
+
[/\.\s*pdf\s*\(/, 'it saves a PDF of the page'],
|
|
61
|
+
[/\btracing\b/, 'it records a trace'],
|
|
62
|
+
[/\brecord(?:Video|Har)\b/, 'it records a video or a HAR'],
|
|
63
|
+
[/\brouteFromHAR\b/, 'it reads a HAR file'],
|
|
64
|
+
[/\.\s*saveAs\s*\(/, 'it saves a download'],
|
|
65
|
+
[/\.\s*storageState\s*\(/, 'it saves the storage state'],
|
|
66
|
+
[/\.\s*setInputFiles\s*\(/, 'it uploads a local file to the page'],
|
|
67
|
+
[/(?:^|[{,\s])['"]?path['"]?\s*:/m, 'it passes a file path to a browser call (a "path:" option)'],
|
|
44
68
|
];
|
|
45
69
|
|
|
46
70
|
/**
|
|
@@ -63,6 +87,63 @@ export function scanReviewScript(text) {
|
|
|
63
87
|
return problems;
|
|
64
88
|
}
|
|
65
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The capture(name, check, { fullPage }) helper given to a review script. `check` asserts the state the shot shows and
|
|
92
|
+
* runs first: when it fails no screenshot is taken and the capture is recorded as failed, without throwing into the
|
|
93
|
+
* script. A good capture is saved as <out>/<name>.png. Nothing is returned, so the image never reaches the script.
|
|
94
|
+
* `captures` collects { name, ok, path | error }, the path relative to `cwd` when the file is under it; a repeated
|
|
95
|
+
* name replaces the earlier entry, as its file does.
|
|
96
|
+
*/
|
|
97
|
+
export function makeCapture({ page, out, captures, cwd = process.cwd() }) {
|
|
98
|
+
const keep = (entry) => {
|
|
99
|
+
const i = captures.findIndex((c) => c.name === entry.name);
|
|
100
|
+
if (i === -1) captures.push(entry);
|
|
101
|
+
else captures[i] = entry;
|
|
102
|
+
};
|
|
103
|
+
return async (name, check, options = {}) => {
|
|
104
|
+
const safe = safeCaptureName(name);
|
|
105
|
+
if (typeof check !== 'function') throw new Error(`capture("${safe}", check): check is the assertion of the state the shot shows, and it is required`);
|
|
106
|
+
const unknown = Object.keys(options ?? {}).filter((k) => k !== 'fullPage');
|
|
107
|
+
if (unknown.length > 0) throw new Error(`capture("${safe}"): the only option is fullPage (got ${unknown.join(', ')})`);
|
|
108
|
+
try {
|
|
109
|
+
await check();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
keep({ name: safe, ok: false, error: String(error?.message ?? error).split('\n')[0] });
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const file = join(out, `${safe}.png`);
|
|
115
|
+
await page.screenshot({ path: file, fullPage: options?.fullPage === true });
|
|
116
|
+
const near = relative(cwd, file);
|
|
117
|
+
keep({ name: safe, ok: true, path: near && !near.startsWith('..') && !isAbsolute(near) ? near : file });
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The page's own failure during a run, to name before a script error: the first uncaught error of the page or the
|
|
124
|
+
* first response of 400 or more from the origin under review. `events` are those of formatBrowserLog. Returns a
|
|
125
|
+
* string or null.
|
|
126
|
+
*/
|
|
127
|
+
export function probableCause(events, baseUrl) {
|
|
128
|
+
let origin;
|
|
129
|
+
try {
|
|
130
|
+
origin = new URL(baseUrl).origin;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const hit = events.find((e) => {
|
|
135
|
+
if (e.type === 'pageerror') return true;
|
|
136
|
+
if (e.type !== 'response') return false;
|
|
137
|
+
try {
|
|
138
|
+
return new URL(String(e.text).split(' ').pop()).origin === origin;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (!hit) return null;
|
|
144
|
+
return hit.type === 'pageerror' ? `uncaught ${hit.text}` : `response ${hit.text}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
66
147
|
/** Replace the value of every secret by [redacted:NAME]. `secrets` maps a variable name to its value. */
|
|
67
148
|
export function redact(text, secrets = {}) {
|
|
68
149
|
let out = String(text ?? '');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Where the worktree of an issue lives: next to the repository, never inside it. Shared by `go-local` and
|
|
2
|
+
// `go-worktree` so that the two cannot name it differently.
|
|
3
|
+
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const WORKTREES_FOLDER = '.relevo-worktrees';
|
|
7
|
+
|
|
8
|
+
/** The worktree of an issue: `<parent of the repository>/.relevo-worktrees/<repository>-<issue>`. Pure. */
|
|
9
|
+
export function worktreePath(repoRoot, issue) {
|
|
10
|
+
return join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
|
|
11
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Prints how `review` works for a platform of relevo.yml, as JSON. Usage:
|
|
3
3
|
// node scripts/review-adapter.mjs --platform web [--target web] [--config relevo.yml]
|
|
4
|
-
// Output: { supported, stack, target, kind, log, capture, driver, needs, reason, platform, needs_autopilot }
|
|
4
|
+
// Output: { supported, stack, target, kind, log, capture, driver, needs, reason, platform, needs_autopilot, advice }
|
|
5
|
+
// `advice` is what to tell the person, in the language of relevo.yml, when a browser review cannot start with review.autopilot off.
|
|
5
6
|
// exit 0: supported exit 1: no adapter for this platform and target (the reason is in the output) exit 2: bad usage or invalid relevo.yml
|
|
6
7
|
|
|
7
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
9
|
import { resolve } from 'node:path';
|
|
9
10
|
import { loadConfig } from './lib/config.mjs';
|
|
10
|
-
import { adapterFor, needsAutopilot, targetOf } from './lib/review-adapters.mjs';
|
|
11
|
+
import { adapterFor, autopilotAdvice, needsAutopilot, targetOf } from './lib/review-adapters.mjs';
|
|
11
12
|
|
|
12
13
|
const arg = (name) => {
|
|
13
14
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -28,5 +29,6 @@ if (!platform) fail(`there is no platform "${name}" in relevo.yml (platforms: ${
|
|
|
28
29
|
|
|
29
30
|
const target = arg('target') ?? targetOf(platform);
|
|
30
31
|
const adapter = adapterFor({ stack: platform.stack, target });
|
|
31
|
-
|
|
32
|
+
const advice = autopilotAdvice(adapter, { autopilot: loaded.config.review?.autopilot, language: loaded.config.language });
|
|
33
|
+
console.log(JSON.stringify({ ...adapter, platform: name, needs_autopilot: needsAutopilot(adapter), advice }));
|
|
32
34
|
process.exit(adapter.supported ? 0 : 1);
|
package/scripts/review-brief.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Prints a short brief of an issue for the person who reviews it. Usage:
|
|
3
3
|
// node scripts/review-brief.mjs --issue 42 --title "Birthday list" --body-file issue.md [--built-file comment.md] [--language en|es]
|
|
4
|
+
// [--autopilot off|precheck|verify]
|
|
4
5
|
// exit 0: printed exit 2: bad arguments
|
|
5
6
|
|
|
6
7
|
import { readFileSync } from 'node:fs';
|
|
@@ -14,7 +15,7 @@ const number = arg('issue');
|
|
|
14
15
|
const title = arg('title');
|
|
15
16
|
const bodyFile = arg('body-file');
|
|
16
17
|
if (!number || title === undefined || !bodyFile) {
|
|
17
|
-
console.error('usage: review-brief.mjs --issue <n> --title "<title>" --body-file <file> [--built-file <file>] [--language en|es]');
|
|
18
|
+
console.error('usage: review-brief.mjs --issue <n> --title "<title>" --body-file <file> [--built-file <file>] [--language en|es] [--autopilot off|precheck|verify]');
|
|
18
19
|
process.exit(2);
|
|
19
20
|
}
|
|
20
21
|
const read = (f) => {
|
|
@@ -26,4 +27,4 @@ const read = (f) => {
|
|
|
26
27
|
}
|
|
27
28
|
};
|
|
28
29
|
const built = arg('built-file') ? read(arg('built-file')) : '';
|
|
29
|
-
console.log(reviewBrief({ issue: { number, title, body: read(bodyFile) }, built, language: arg('language') ?? 'en' }));
|
|
30
|
+
console.log(reviewBrief({ issue: { number, title, body: read(bodyFile) }, built, language: arg('language') ?? 'en', autopilot: arg('autopilot') ?? 'off' }));
|
package/scripts/review-state.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// review-state.mjs init <change-dir> [--autopilot off|precheck|verify] build the checklist from the change's scenarios
|
|
4
4
|
// review-state.mjs set <id> <ok|fail|pending> [note ...]
|
|
5
5
|
// review-state.mjs sync <change-dir> after a fix: keep confirmed lines, retest failed ones, add new scenarios
|
|
6
|
-
// review-state.mjs capture <path> record a screenshot that was taken
|
|
6
|
+
// review-state.mjs capture <path> record a screenshot that was taken (refused after "agree")
|
|
7
|
+
// review-state.mjs qa-pass <script> QA passed this browser review script; the runner runs only this exact content
|
|
7
8
|
// review-state.mjs audit [<log-file>|-] [--package <id>] [--pid <n>]
|
|
8
9
|
// audit the device log (stdin by default), write the report;
|
|
9
10
|
// with --package only that app's lines are audited
|
|
@@ -24,7 +25,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
|
|
|
24
25
|
import { dirname, join } from 'node:path';
|
|
25
26
|
import { checklistFromChange } from './lib/checklist.mjs';
|
|
26
27
|
import { auditLog, auditReport, filterByApp } from './lib/logaudit.mjs';
|
|
27
|
-
import { applyWaivers, autoItem, autoSection, confirmEnv, decidePr, newState, precheckItem, setItem, syncState, waivedSection, waiveFinding } from './lib/review-state.mjs';
|
|
28
|
+
import { applyWaivers, autoItem, autoSection, confirmEnv, decidePr, newState, precheckItem, qaPass, recordCapture, setItem, syncState, waivedSection, waiveFinding } from './lib/review-state.mjs';
|
|
28
29
|
|
|
29
30
|
const argv = process.argv.slice(2);
|
|
30
31
|
const si = argv.indexOf('--state');
|
|
@@ -72,12 +73,18 @@ try {
|
|
|
72
73
|
}
|
|
73
74
|
case 'capture': {
|
|
74
75
|
if (!rest[0]) fail('usage: review-state.mjs capture <path>');
|
|
75
|
-
const state = load();
|
|
76
|
-
state.captures.push(rest[0]);
|
|
76
|
+
const state = recordCapture(load(), rest[0]);
|
|
77
77
|
save(state);
|
|
78
78
|
console.log(`recorded ${rest[0]}`);
|
|
79
79
|
break;
|
|
80
80
|
}
|
|
81
|
+
case 'qa-pass': {
|
|
82
|
+
if (!rest[0] || !existsSync(rest[0])) fail('usage: review-state.mjs qa-pass <script>');
|
|
83
|
+
const state = qaPass(load(), rest[0], readFileSync(rest[0], 'utf8'));
|
|
84
|
+
save(state);
|
|
85
|
+
console.log(`QA passed ${rest[0]} (sha256 ${state.qaScript.sha256.slice(0, 12)}); any change to it needs a new pass`);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
81
88
|
case 'drain': {
|
|
82
89
|
const file = join(dirname(statePath), 'device.log');
|
|
83
90
|
const text = readFileSync(0, 'utf8');
|
|
@@ -164,7 +171,7 @@ try {
|
|
|
164
171
|
break;
|
|
165
172
|
}
|
|
166
173
|
default:
|
|
167
|
-
fail('usage: review-state.mjs <init|set|sync|capture|drain|audit|waive|waived|confirm-env|precheck|auto|auto-list|agree|show|decide> [...]');
|
|
174
|
+
fail('usage: review-state.mjs <init|set|sync|capture|qa-pass|drain|audit|waive|waived|confirm-env|precheck|auto|auto-list|agree|show|decide> [...]');
|
|
168
175
|
}
|
|
169
176
|
} catch (err) {
|
|
170
177
|
fail(err.message);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checks that a browser review can drive: the project's dependencies, Playwright and its Chromium, and the address
|
|
3
|
+
// under review with the module scripts its page loads. Usage:
|
|
4
|
+
// node scripts/web-preflight.mjs --url http://localhost:5173 [--timeout 5000] [--wait 30000]
|
|
5
|
+
// With --wait it is also the readiness probe of a server the agent started: the address is retried until it answers.
|
|
6
|
+
// Prints { ok, check, detail, start, env } as JSON: `start` is the command that starts the app, `env` the variable
|
|
7
|
+
// names of .env.example (the real environment files are never read).
|
|
8
|
+
// exit 0: ready exit 3: no node_modules (run npm install) exit 4: Playwright does not resolve
|
|
9
|
+
// exit 5: the Chromium of Playwright is missing exit 6: the address or one of its modules does not answer below 400
|
|
10
|
+
// exit 2: bad arguments
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { loadPlaywright } from './lib/web-review.mjs';
|
|
14
|
+
import { webPreflight } from './lib/web-preflight.mjs';
|
|
15
|
+
|
|
16
|
+
const arg = (name) => {
|
|
17
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
18
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
19
|
+
};
|
|
20
|
+
const number = (name, fallback) => {
|
|
21
|
+
const v = arg(name);
|
|
22
|
+
if (v === undefined) return fallback;
|
|
23
|
+
const n = Number(v);
|
|
24
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
25
|
+
console.error(`--${name} must be a number of milliseconds`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const url = arg('url');
|
|
32
|
+
try {
|
|
33
|
+
if (!url || !/^https?:$/.test(new URL(url).protocol)) throw new Error();
|
|
34
|
+
} catch {
|
|
35
|
+
console.error('usage: web-preflight.mjs --url <http address> [--timeout 5000] [--wait 30000]');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
const result = await webPreflight({ cwd, url, timeout: number('timeout', 5000), wait: number('wait', 0) }, {
|
|
40
|
+
exists: existsSync,
|
|
41
|
+
readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null),
|
|
42
|
+
loadPlaywright: () => loadPlaywright(cwd),
|
|
43
|
+
fetch: globalThis.fetch,
|
|
44
|
+
});
|
|
45
|
+
const { code, ...shown } = result;
|
|
46
|
+
console.log(JSON.stringify(shown));
|
|
47
|
+
process.exit(code);
|
package/scripts/web-review.mjs
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Runs a review script in a browser of its own and reports what it checked. Usage:
|
|
3
3
|
// node scripts/web-review.mjs --script evidence/review-web.mjs --url http://localhost:5173 \
|
|
4
|
-
// [--allow-origins https://staging.example.com] [--secret-env TEST_USER,TEST_PASS] [--out evidence]
|
|
4
|
+
// [--allow-origins https://staging.example.com] [--secret-env TEST_USER,TEST_PASS] [--out evidence]
|
|
5
|
+
// [--state evidence/review-state.json] [--headed]
|
|
5
6
|
// The script is the agent's, written from the scenarios and reviewed by QA. Its default export receives
|
|
6
|
-
// { page, context, baseUrl, line, credentials }: line(n, how, fn) runs fn and records the checklist line n as
|
|
7
|
+
// { page, context, baseUrl, line, credentials, capture }: line(n, how, fn) runs fn and records the checklist line n as
|
|
7
8
|
// auto-ok or auto-fail; credentials(name) gives a test credential taken from the environment variable `name`,
|
|
8
|
-
// which must be in --secret-env
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
// which must be in --secret-env; capture(name, check, { fullPage }) runs the assertion `check` and, when it holds,
|
|
10
|
+
// saves <out>/<name>.png and records it in the review state (nothing of the image is given back or printed).
|
|
11
|
+
// Before anything runs, the review state must say the autopilot is on, the data was confirmed, the review is not
|
|
12
|
+
// agreed yet, and QA passed this exact script (review-state.mjs qa-pass). The browser has a clean profile and can
|
|
13
|
+
// reach only localhost, the page under review and the allowed origins; every secret value is redacted from what is
|
|
14
|
+
// written, and the script's own console goes to <out>/script-console.txt, never to this output.
|
|
15
|
+
// Writes <out>/web-log.txt (the log the audit reads, with --package web) and <out>/web-result.json. Each run against
|
|
16
|
+
// another server uses an --out of its own; only the log of the normal run is audited.
|
|
17
|
+
// exit 0: the script ran (lines and captures can still have failed) exit 1: the review state refused the run,
|
|
18
|
+
// the script was refused, Playwright is missing, the browser could not start, or the script failed outside a line
|
|
19
|
+
// exit 2: bad arguments
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
22
|
import { join, resolve } from 'node:path';
|
|
17
23
|
import { pathToFileURL } from 'node:url';
|
|
18
|
-
import {
|
|
24
|
+
import { format } from 'node:util';
|
|
25
|
+
import { assertDriving, recordCapture, scriptHash } from './lib/review-state.mjs';
|
|
26
|
+
import { formatBrowserLog, isAllowedOrigin, loadPlaywright, makeCapture, probableCause, redact, scanReviewScript } from './lib/web-review.mjs';
|
|
19
27
|
|
|
20
28
|
const RUN_LIMIT_MS = 120000;
|
|
29
|
+
// The runner's own output. The script's console is taken over while it runs, so these are kept apart.
|
|
30
|
+
const say = console.log.bind(console);
|
|
31
|
+
const tell = console.error.bind(console);
|
|
21
32
|
|
|
22
33
|
const arg = (name) => {
|
|
23
34
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -25,13 +36,13 @@ const arg = (name) => {
|
|
|
25
36
|
};
|
|
26
37
|
const list = (name) => (arg(name) ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
27
38
|
const stop = (code, message) => {
|
|
28
|
-
|
|
39
|
+
tell(message);
|
|
29
40
|
process.exit(code);
|
|
30
41
|
};
|
|
31
42
|
|
|
32
43
|
const scriptPath = arg('script');
|
|
33
44
|
const baseUrl = arg('url');
|
|
34
|
-
if (!scriptPath || !baseUrl || !existsSync(scriptPath)) stop(2, 'usage: web-review.mjs --script <file> --url <url> [--allow-origins a,b] [--secret-env A,B] [--out evidence] [--headed]');
|
|
45
|
+
if (!scriptPath || !baseUrl || !existsSync(scriptPath)) stop(2, 'usage: web-review.mjs --script <file> --url <url> [--allow-origins a,b] [--secret-env A,B] [--out evidence] [--state evidence/review-state.json] [--headed]');
|
|
35
46
|
let origin;
|
|
36
47
|
try {
|
|
37
48
|
origin = new URL(baseUrl);
|
|
@@ -41,26 +52,38 @@ try {
|
|
|
41
52
|
const allowed = list('allow-origins');
|
|
42
53
|
const secretNames = list('secret-env');
|
|
43
54
|
const out = resolve(arg('out') ?? 'evidence');
|
|
55
|
+
const statePath = resolve(arg('state') ?? 'evidence/review-state.json');
|
|
56
|
+
|
|
57
|
+
const readState = () => JSON.parse(readFileSync(statePath, 'utf8'));
|
|
58
|
+
if (!existsSync(statePath)) stop(1, `no review in progress (${statePath} not found): the runner drives only a review started with review-state.mjs init`);
|
|
59
|
+
try {
|
|
60
|
+
assertDriving(readState(), { open: true });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
stop(1, `the review state refused the run before any browser started: ${error.message}`);
|
|
63
|
+
}
|
|
44
64
|
|
|
45
|
-
const
|
|
65
|
+
const source = readFileSync(scriptPath, 'utf8');
|
|
66
|
+
const problems = scanReviewScript(source);
|
|
46
67
|
if (problems.length > 0) stop(1, `the review script was refused before any browser started:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
68
|
+
if (readState().qaScript?.sha256 !== scriptHash(source)) {
|
|
69
|
+
stop(1, `the review script is not the one QA passed: launch relevo:qa on it, and after QA PASSED run review-state.mjs qa-pass ${scriptPath}`);
|
|
70
|
+
}
|
|
47
71
|
|
|
48
72
|
const secrets = Object.fromEntries(secretNames.filter((n) => process.env[n]).map((n) => [n, process.env[n]]));
|
|
49
73
|
const hide = (text) => redact(text, secrets);
|
|
50
74
|
|
|
51
|
-
function loadPlaywright() {
|
|
52
|
-
for (const base of [join(process.cwd(), 'package.json'), import.meta.url]) {
|
|
53
|
-
try {
|
|
54
|
-
return createRequire(base)('playwright');
|
|
55
|
-
} catch { /* try the next place */ }
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
75
|
const playwright = loadPlaywright();
|
|
60
|
-
if (!playwright) stop(1, 'Playwright cannot be resolved from this project: install it with "npm install -
|
|
76
|
+
if (!playwright) stop(1, 'Playwright cannot be resolved from this project: with the person\'s permission, install it with "npm install --no-save playwright" and "npx playwright install chromium", then restore package-lock.json if npm changed it');
|
|
77
|
+
|
|
78
|
+
if (arg('out') === undefined && existsSync(join(out, 'web-log.txt'))) {
|
|
79
|
+
tell(`warning: ${join(out, 'web-log.txt')} is replaced; a run against another server uses an --out of its own (evidence/run-<port>)`);
|
|
80
|
+
}
|
|
81
|
+
mkdirSync(out, { recursive: true });
|
|
61
82
|
|
|
62
83
|
const events = [];
|
|
63
84
|
const results = [];
|
|
85
|
+
const captures = [];
|
|
86
|
+
const scriptConsole = [];
|
|
64
87
|
let scriptError = null;
|
|
65
88
|
let pagesLoaded = 0;
|
|
66
89
|
let browser;
|
|
@@ -80,6 +103,13 @@ const credentials = (name) => {
|
|
|
80
103
|
return secrets[name];
|
|
81
104
|
};
|
|
82
105
|
|
|
106
|
+
// From here on the script's console goes to a file, also after a timeout: the runner prints with say and tell.
|
|
107
|
+
function takeOverConsole() {
|
|
108
|
+
['log', 'info', 'warn', 'error', 'debug', 'trace', 'dir', 'table'].forEach((level) => {
|
|
109
|
+
console[level] = (...args) => { scriptConsole.push(`${level} ${format(...args)}`); };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
async function run() {
|
|
84
114
|
try {
|
|
85
115
|
browser = await playwright.chromium.launch({ headless: !process.argv.includes('--headed') });
|
|
@@ -105,11 +135,14 @@ async function run() {
|
|
|
105
135
|
if (r.request().isNavigationRequest() && r.request().frame() === page.mainFrame() && r.status() < 400) pagesLoaded += 1;
|
|
106
136
|
});
|
|
107
137
|
page.on('response', (r) => { if (r.status() >= 400) events.push({ type: 'response', text: `${r.status()} ${r.request().method()} ${r.url()}` }); });
|
|
138
|
+
// The real path, so a capture is recorded relative to the working directory even through a symbolic link.
|
|
139
|
+
const capture = makeCapture({ page, out: realpathSync(out), captures });
|
|
108
140
|
|
|
109
141
|
try {
|
|
142
|
+
takeOverConsole();
|
|
110
143
|
const mod = await import(pathToFileURL(resolve(scriptPath)).href);
|
|
111
144
|
if (typeof mod.default !== 'function') throw new Error('the script must export a default async function');
|
|
112
|
-
await mod.default({ page, context, baseUrl, line, credentials });
|
|
145
|
+
await mod.default({ page, context, baseUrl, line, credentials, capture });
|
|
113
146
|
} catch (error) {
|
|
114
147
|
scriptError = String(error?.message ?? error).split('\n')[0];
|
|
115
148
|
}
|
|
@@ -124,11 +157,29 @@ clearTimeout(timer);
|
|
|
124
157
|
clearTimeout(backstop);
|
|
125
158
|
if (browser) await browser.close().catch(() => {});
|
|
126
159
|
|
|
127
|
-
mkdirSync(out, { recursive: true });
|
|
128
160
|
// A browser that never loaded a page proves nothing: the log is left empty so the audit is inconclusive.
|
|
129
161
|
writeFileSync(join(out, 'web-log.txt'), pagesLoaded === 0 ? '' : hide(formatBrowserLog(events, { origin: origin.host })));
|
|
130
|
-
writeFileSync(join(out, 'web-result.json'), hide(`${JSON.stringify({ url: baseUrl, lines: results, blocked: events.filter((e) => e.type === 'blocked').length, pagesLoaded, scriptError }, null, 2)}\n`));
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
162
|
+
writeFileSync(join(out, 'web-result.json'), hide(`${JSON.stringify({ url: baseUrl, lines: results, captures, blocked: events.filter((e) => e.type === 'blocked').length, pagesLoaded, scriptError }, null, 2)}\n`));
|
|
163
|
+
writeFileSync(join(out, 'script-console.txt'), hide(scriptConsole.map((l) => `${l}\n`).join('')));
|
|
164
|
+
|
|
165
|
+
// The state is read again, so a line set while the browser ran is not lost.
|
|
166
|
+
const saved = captures.filter((c) => c.ok);
|
|
167
|
+
if (saved.length > 0) {
|
|
168
|
+
try {
|
|
169
|
+
const state = readState();
|
|
170
|
+
saved.forEach((c) => recordCapture(state, c.path));
|
|
171
|
+
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
tell(`the captures could not be recorded in ${statePath}: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
results.sort((a, b) => a.n - b.n).forEach((r) => say(hide(`line ${r.n}: ${r.status} - ${r.how}${r.error ? ` (${r.error})` : ''}`)));
|
|
178
|
+
captures.forEach((c) => say(hide(c.ok ? `captured ${c.path}` : `capture ${c.name}: failed (${c.error})`)));
|
|
179
|
+
say(`${events.length} event(s) written to ${join(out, 'web-log.txt')}; audit it with: review-state.mjs audit --package web`);
|
|
180
|
+
if (scriptError) {
|
|
181
|
+
const cause = probableCause(events, baseUrl);
|
|
182
|
+
if (cause) tell(hide(`the page itself failed: ${cause}`));
|
|
183
|
+
stop(1, hide(`the script failed outside a line: ${scriptError}`));
|
|
184
|
+
}
|
|
134
185
|
process.exit(0);
|
package/skills/go/SKILL.md
CHANGED
|
@@ -27,10 +27,22 @@ Launch `relevo:dev` and `relevo:qa` in the foreground and wait for each report b
|
|
|
27
27
|
|
|
28
28
|
## Where it runs
|
|
29
29
|
|
|
30
|
-
In the cloud the workflow starts this skill with the decision already made. A person can also start it on their machine: `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>` runs it in a separate git worktree, with the same limits and tool lists as the workflow, and removes the worktree when it ends cleanly.
|
|
30
|
+
In the cloud the workflow starts this skill with the decision already made. A person can also start it on their machine: `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>` runs it in a separate git worktree, with the same limits and tool lists as the workflow, and removes the worktree when it ends cleanly.
|
|
31
31
|
|
|
32
32
|
**When `$ARGUMENTS` has `--local`**, do not build in this session: run `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>`, show what it prints, and stop. The build happens in the process that script starts, in its own worktree and with the closed tool lists, which a plain session does not have.
|
|
33
33
|
|
|
34
|
+
**In an interactive session** (started by hand without `--decision` and without `--local`), do not build in the folder you were started in: another session may be building another issue there, and the person may have changes of their own. Before step 0, make a worktree for the issue:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/go-worktree.mjs create <issue>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Exit 0: it prints `{"worktree": "<path>", "base": "<base>"}`. Enter it with `EnterWorktree` and its `path` (never its `name`, which would branch from the default branch and not from the base of the project). From then on every step, command and agent works in that folder.
|
|
41
|
+
- Exit 4: the session is already in a linked worktree (a run of `go-local.mjs`, or one the person made). Build where you are, make no worktree, and skip the exit below.
|
|
42
|
+
- Any other exit (for example 3, the worktree of this issue already exists because another run is building it or a failed one left it): show its message and stop without changing anything.
|
|
43
|
+
|
|
44
|
+
At the end of the run, before the final report, leave the worktree: call `ExitWorktree` with `keep`. Then, from the folder you came from, remove it with `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-worktree.mjs remove <issue>` when the outcome is `built` or a gate decision that stopped before step 2 (nothing was built, so nothing is lost). After the failure path, or if the run stopped for any other reason, leave the worktree on disk and say its path in the report.
|
|
45
|
+
|
|
34
46
|
## OpenSpec commands
|
|
35
47
|
|
|
36
48
|
Use these OpenSpec commands and no others:
|
package/skills/init/SKILL.md
CHANGED
|
@@ -36,7 +36,7 @@ If `relevo.yml` already exists and is valid, skip this step. Otherwise ask, in s
|
|
|
36
36
|
- the platforms: for each one a name, its stack (look at the project to propose one: the profiles in `${CLAUDE_PLUGIN_ROOT}/stacks/` are the stacks Relevo ships with: `flutter`, `vue`, `laravel` and `supabase`), the folder where it lives (`.` for a repository with one platform, `apps/web` in a monorepo) and, for a stack that has targets, which ones (for Flutter: `android`, `ios`, `web`; for Vue: `web`; Laravel and Supabase have none). Several platforms in one repository are written down, but `go` builds only a repository with one for now;
|
|
37
37
|
- where `go` builds: in the cloud, on each person's machine, or both (the default), and, for the cloud, with an API key from the Anthropic Console (the default) or with a subscription token. A subscription's quota is shared by every run and by the interactive use of the account, so `init` sets `limits.concurrent_agents` to 1 for it. Nothing about this goes in `relevo.yml` apart from that number: the installed workflows, the secret that exists and the repository variable `RELEVO_CLOUD_GO` (`off` pauses the cloud at once, with no pull request) decide it;
|
|
38
38
|
- the repositories this one depends on, if any (`owner/repo`, for example the back end of a front end): they are written to `links`, the closed list that `Depends-on: owner/repo#N` is checked against;
|
|
39
|
-
- whether to create a commented `.relevo/stacks/<stack>.md` for each stack, where the project can add its own hygiene to the plugin's profile (optional; it
|
|
39
|
+
- whether to create a commented `.relevo/stacks/<stack>.md` for each stack, where the project can add its own hygiene to the plugin's profile (optional; `init` creates it but does not commit it: it is left for the person to commit, because the cloud and teammates read it from git);
|
|
40
40
|
- which agent builds in the cloud, only when the person says they use Antigravity: `claude` (the default) or `antigravity`. With `antigravity` the model key is `GEMINI_API_KEY`, a key made only for Relevo in its own Google Cloud project with a quota, and the limit asked below is a number of tokens (`limits.run_max_tokens`) because that agent has no cost limit; `docs/security.md` says what else it cannot enforce;
|
|
41
41
|
- the cost limit per unattended run in US dollars: the template's 5 is a placeholder the person must accept or change (with `antigravity`, the token limit instead).
|
|
42
42
|
|
|
@@ -72,4 +72,4 @@ Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/doctor.mjs` and show its output.
|
|
|
72
72
|
|
|
73
73
|
## Report
|
|
74
74
|
|
|
75
|
-
Finish with: what was created, what was left alone, what the person must still do by hand (always the model credential in the repository secrets, with all whitespace removed from a subscription token, and committing and pushing the new files to the default branch, because a workflow only runs from there), and the doctor result.
|
|
75
|
+
Finish with: what was created, what was left alone, what the person must still do by hand (always the model credential in the repository secrets, with all whitespace removed from a subscription token, and committing and pushing the new files to the default branch, because a workflow only runs from there; name each `.relevo/stacks/<stack>.md` it created among them), and the doctor result.
|