@elalfner/relevo 0.6.0 → 0.8.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/init.mjs +3 -1
- package/scripts/lib/can-edit-issue.mjs +4 -1
- package/scripts/lib/config.mjs +19 -1
- package/scripts/lib/dev-server-guard.mjs +49 -0
- package/scripts/lib/doctor.mjs +136 -5
- package/scripts/lib/extra-server.mjs +84 -0
- package/scripts/lib/github.mjs +15 -5
- package/scripts/lib/init.mjs +63 -3
- package/scripts/lib/labels.mjs +4 -0
- package/scripts/lib/messages.mjs +109 -0
- package/scripts/lib/next-step.mjs +12 -0
- package/scripts/lib/plan.mjs +29 -6
- package/scripts/lib/preflight.mjs +1 -1
- package/scripts/lib/review-adapters.mjs +15 -2
- package/scripts/lib/review-brief.mjs +12 -4
- package/scripts/lib/review-events.mjs +6 -3
- package/scripts/lib/review-state.mjs +30 -4
- package/scripts/lib/ship-github.mjs +87 -0
- package/scripts/lib/ship-unattended.mjs +271 -0
- package/scripts/lib/ship.mjs +15 -3
- package/scripts/lib/toolchain-check.mjs +6 -0
- package/scripts/lib/web-preflight.mjs +133 -0
- package/scripts/lib/web-review.mjs +82 -1
- package/scripts/review-adapter.mjs +5 -3
- package/scripts/review-brief.mjs +3 -2
- package/scripts/review-event.mjs +10 -1
- package/scripts/review-state.mjs +12 -5
- package/scripts/ship-check.mjs +6 -4
- package/scripts/ship-unattended.mjs +45 -0
- package/scripts/toolchain-check.mjs +3 -3
- package/scripts/web-hook.mjs +28 -0
- package/scripts/web-preflight.mjs +48 -0
- package/scripts/web-review.mjs +80 -29
- package/skills/doctor/SKILL.md +3 -2
- package/skills/go/SKILL.md +4 -2
- package/skills/init/SKILL.md +7 -2
- package/skills/review/SKILL.md +49 -10
- package/skills/ship/SKILL.md +3 -1
- package/stacks/vue.md +2 -2
- package/templates/agents/antigravity-step.yml +8 -5
- package/templates/relevo.yml +4 -0
- package/templates/workflows/relevo-digest.yml +10 -1
- package/templates/workflows/relevo-go.yml +25 -5
- package/templates/workflows/relevo-queue.yml +10 -1
- package/templates/workflows/relevo-review.yml +10 -1
- package/templates/workflows/relevo-ship.yml +135 -0
- package/templates/workflows/relevo-sla.yml +10 -1
|
@@ -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 ?? '');
|
|
@@ -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-event.mjs
CHANGED
|
@@ -52,7 +52,16 @@ if (eventName === 'pull_request_review') {
|
|
|
52
52
|
|
|
53
53
|
const pr = { number: prData.number, url: prData.html_url, author: prData.user.login };
|
|
54
54
|
const labels = JSON.parse(realGh(['api', `repos/${repo}/issues/${number}`])).labels.map((l) => (typeof l === 'string' ? l : l.name));
|
|
55
|
-
|
|
55
|
+
// The approval names the ship label only where the ship workflow runs: it runs from the default branch.
|
|
56
|
+
const shipWorkflow = event.kind === 'review' && event.state === 'approved' && (() => {
|
|
57
|
+
try {
|
|
58
|
+
realGh(['api', `repos/${repo}/contents/.github/workflows/relevo-ship.yml`, '--silent']);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const decision = planReviewEvent({ config, event, pr, issue: { number, labels }, shipWorkflow });
|
|
56
65
|
if (!decision) {
|
|
57
66
|
console.log(JSON.stringify({ action: 'none' }));
|
|
58
67
|
process.exit(0);
|
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);
|
package/scripts/ship-check.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { resolve } from 'node:path';
|
|
|
11
11
|
import { loadConfig } from './lib/config.mjs';
|
|
12
12
|
import { realGh } from './lib/github.mjs';
|
|
13
13
|
import { shipDecision } from './lib/ship.mjs';
|
|
14
|
+
import { baselineChecks, loadPr } from './lib/ship-github.mjs';
|
|
14
15
|
|
|
15
16
|
const arg = (name) => {
|
|
16
17
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -26,9 +27,9 @@ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /r
|
|
|
26
27
|
const loaded = loadConfig(readFileSync(configPath, 'utf8'));
|
|
27
28
|
if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
|
|
28
29
|
|
|
29
|
-
const FIELDS = 'number,state,isDraft,mergeable,title,author,reviews,statusCheckRollup,headRefName,headRefOid,baseRefName';
|
|
30
30
|
let pr;
|
|
31
31
|
let invoker = arg('invoker');
|
|
32
|
+
let carried;
|
|
32
33
|
|
|
33
34
|
if (arg('input')) {
|
|
34
35
|
pr = JSON.parse(readFileSync(arg('input'), 'utf8'));
|
|
@@ -36,10 +37,10 @@ if (arg('input')) {
|
|
|
36
37
|
const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
|
|
37
38
|
const branch = arg('branch');
|
|
38
39
|
if (!repo || !branch) fail('usage: ship-check.mjs --repo owner/name --branch <branch> [--invoker login] [--config relevo.yml]');
|
|
39
|
-
|
|
40
|
-
const open = found.find((p) => p.state === 'OPEN') ?? found[0];
|
|
41
|
-
pr = open ? JSON.parse(realGh(['pr', 'view', String(open.number), '--repo', repo, '--json', FIELDS])) : null;
|
|
40
|
+
pr = loadPr(realGh, repo, branch);
|
|
42
41
|
invoker ??= realGh(['api', 'user', '--jq', '.login']).trim();
|
|
42
|
+
// The archive commit may have no checks of its own; the approved head's count when only openspec/ changed since.
|
|
43
|
+
if (pr && arg('baseline')) carried = baselineChecks(realGh, repo, arg('baseline'), pr.headRefOid);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const decision = shipDecision({
|
|
@@ -49,6 +50,7 @@ const decision = shipDecision({
|
|
|
49
50
|
baseline: arg('baseline'),
|
|
50
51
|
labels: arg('labels') === undefined ? undefined : arg('labels').split(',').map((l) => l.trim()).filter(Boolean),
|
|
51
52
|
issue: arg('issue'),
|
|
53
|
+
baselineChecks: carried,
|
|
52
54
|
});
|
|
53
55
|
console.log(JSON.stringify(decision, null, 2));
|
|
54
56
|
process.exit(decision.allowed ? 0 : 1);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ships an issue on GitHub, without a model, when a person applied the `relevo:ship` label. Usage (in the workflow):
|
|
3
|
+
// node scripts/ship-unattended.mjs --repo owner/name --issue 31 --actor login --actor-type User --work <checkout>
|
|
4
|
+
// Environment: GH_TOKEN (the workflow token), GH_PUSH_TOKEN (the App token, or the workflow token), HAS_APP (true|false).
|
|
5
|
+
// Prints the outcome as JSON. exit 0: merged, stopped with a comment, or ignored exit 2: bad arguments
|
|
6
|
+
// Any other failure exits non-zero, and the workflow's last step reports it on the issue.
|
|
7
|
+
|
|
8
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { runUnattendedShip } from './lib/ship-unattended.mjs';
|
|
12
|
+
|
|
13
|
+
const arg = (name) => {
|
|
14
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
15
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
16
|
+
};
|
|
17
|
+
const [repo, issue, actor, actorType, work] = ['repo', 'issue', 'actor', 'actor-type', 'work'].map(arg);
|
|
18
|
+
if (!repo || !/^\d+$/.test(issue ?? '') || !actor || !actorType || !work) {
|
|
19
|
+
console.error('usage: ship-unattended.mjs --repo owner/name --issue <n> --actor <login> --actor-type <User|Bot> --work <checkout>');
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ghWith = (token) => (args) => execFileSync('gh', args, {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
26
|
+
env: { ...process.env, GH_TOKEN: token ?? process.env.GH_TOKEN },
|
|
27
|
+
});
|
|
28
|
+
const sh = (cmd, args, { cwd, input, env } = {}) => {
|
|
29
|
+
const r = spawnSync(cmd, args, { cwd, input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env } });
|
|
30
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? String(r.error ?? '') };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const outcome = await runUnattendedShip({
|
|
34
|
+
repo,
|
|
35
|
+
issue,
|
|
36
|
+
actor,
|
|
37
|
+
actorType,
|
|
38
|
+
hasApp: process.env.HAS_APP === 'true',
|
|
39
|
+
workDir: resolve(work),
|
|
40
|
+
pluginRoot: resolve(dirname(fileURLToPath(import.meta.url)), '..'),
|
|
41
|
+
sh,
|
|
42
|
+
gh: ghWith(process.env.GH_TOKEN),
|
|
43
|
+
pushGh: ghWith(process.env.GH_PUSH_TOKEN || process.env.GH_TOKEN),
|
|
44
|
+
});
|
|
45
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Checks that the tools which the profile of a platform lists are on the path of this machine. Usage:
|
|
3
3
|
// node scripts/toolchain-check.mjs --platform <name> [--config relevo.yml] [--plugin-root <dir>]
|
|
4
|
-
// It prints { platform, stack, tools, missing } as JSON. A tool is there when `<tool> --version` starts. The cloud build
|
|
4
|
+
// It prints { platform, stack, tools, missing } as JSON; `missing` also covers git, gh, jq and node, which every job needs. A tool is there when `<tool> --version` starts. The cloud build
|
|
5
5
|
// runs it before the agent, so that a runner without the SDK stops in seconds and not after a full run.
|
|
6
6
|
// exit 0: every tool is there (or the profile lists none) exit 1: a tool is missing, or the profile cannot be resolved
|
|
7
7
|
// exit 2: bad usage or an invalid relevo.yml
|
|
@@ -12,7 +12,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { loadConfig } from './lib/config.mjs';
|
|
14
14
|
import { resolveProfile } from './lib/stack-profile.mjs';
|
|
15
|
-
import { missingTools } from './lib/toolchain-check.mjs';
|
|
15
|
+
import { missingTools, RUNNER_TOOLS } from './lib/toolchain-check.mjs';
|
|
16
16
|
|
|
17
17
|
const arg = (name) => {
|
|
18
18
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -44,6 +44,6 @@ const resolved = resolveProfile({ platform, pluginRoot, read });
|
|
|
44
44
|
if (!resolved.ok) fail(resolved.errors.join('\n'), 1);
|
|
45
45
|
|
|
46
46
|
const probe = (tool) => spawnSync(tool, ['--version'], { stdio: 'ignore' }).error === undefined;
|
|
47
|
-
const missing = missingTools(resolved.profile.tools, probe);
|
|
47
|
+
const missing = missingTools([...RUNNER_TOOLS, ...(resolved.profile.tools ?? [])], probe);
|
|
48
48
|
console.log(JSON.stringify({ platform: name, stack: resolved.profile.stack, tools: resolved.profile.tools, missing }));
|
|
49
49
|
process.exit(missing.length > 0 ? 1 : 0);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Adds the SessionStart hook that installs OpenSpec in web sessions to .claude/settings.json. Usage:
|
|
3
|
+
// node scripts/web-hook.mjs [--settings .claude/settings.json]
|
|
4
|
+
// Run by /relevo:init only after the person agreed to the hook. Every other key and hook of the file is kept, and a
|
|
5
|
+
// file that is not a JSON object is left as it is.
|
|
6
|
+
// exit 0: written, or already there exit 2: the file could not be merged
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { mergeWebHook } from './lib/init.mjs';
|
|
11
|
+
|
|
12
|
+
const i = process.argv.indexOf('--settings');
|
|
13
|
+
const path = i === -1 ? '.claude/settings.json' : process.argv[i + 1];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
17
|
+
const after = mergeWebHook(before);
|
|
18
|
+
if (after === before) {
|
|
19
|
+
console.log(`${path} already installs OpenSpec at the start of a web session; nothing changed`);
|
|
20
|
+
} else {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
writeFileSync(path, after);
|
|
23
|
+
console.log(`${path} now installs OpenSpec at the start of a web session; the file is left for you to commit`);
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`could not add the hook to ${path}: ${err.message}`);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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, install } as JSON: `start` is the command that starts the app, `env` the
|
|
7
|
+
// variable names of .env.example (the real environment files are never read), `install` the frozen install command of
|
|
8
|
+
// the lock file (null without one).
|
|
9
|
+
// exit 0: ready exit 3: no node_modules exit 7: node_modules does not match package-lock.json
|
|
10
|
+
// exit 4: Playwright does not resolve from the project exit 5: the Chromium of Playwright is missing
|
|
11
|
+
// exit 6: the address or one of its modules does not answer below 400 exit 2: bad arguments
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { loadPlaywright } from './lib/web-review.mjs';
|
|
15
|
+
import { webPreflight } from './lib/web-preflight.mjs';
|
|
16
|
+
|
|
17
|
+
const arg = (name) => {
|
|
18
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
19
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
20
|
+
};
|
|
21
|
+
const number = (name, fallback) => {
|
|
22
|
+
const v = arg(name);
|
|
23
|
+
if (v === undefined) return fallback;
|
|
24
|
+
const n = Number(v);
|
|
25
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
26
|
+
console.error(`--${name} must be a number of milliseconds`);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
return n;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const url = arg('url');
|
|
33
|
+
try {
|
|
34
|
+
if (!url || !/^https?:$/.test(new URL(url).protocol)) throw new Error();
|
|
35
|
+
} catch {
|
|
36
|
+
console.error('usage: web-preflight.mjs --url <http address> [--timeout 5000] [--wait 30000]');
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const result = await webPreflight({ cwd, url, timeout: number('timeout', 5000), wait: number('wait', 0) }, {
|
|
41
|
+
exists: existsSync,
|
|
42
|
+
readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null),
|
|
43
|
+
loadPlaywright: () => loadPlaywright(cwd),
|
|
44
|
+
fetch: globalThis.fetch,
|
|
45
|
+
});
|
|
46
|
+
const { code, ...shown } = result;
|
|
47
|
+
console.log(JSON.stringify(shown));
|
|
48
|
+
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:
|
|
76
|
+
if (!playwright) stop(1, 'Playwright cannot be resolved from this project: it belongs in its development dependencies; run /relevo:init, or "npm install -D playwright" and "npx playwright install chromium" in the folder of the platform, and commit the change on the base branch');
|
|
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/doctor/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: doctor
|
|
3
|
-
description: Checks that this machine, the repository and GitHub are ready for Relevo and reports every check as pass, fail or warning, with the fix next to each failure. It covers the required tools (OpenSpec, git, the GitHub CLI, the toolchain and the targets of each platform's stack profile), the skills that /relevo:think invokes, the extra skills of each role, relevo.yml, labels and branches, branch protection and approvals, the automation secret and workflows, and a CI check. Read-only: it never changes anything. Use when asked to check, diagnose or verify the Relevo setup.
|
|
3
|
+
description: Checks that this machine, the repository and GitHub are ready for Relevo and reports every check as pass, fail or warning, with the fix next to each failure. It covers the required tools (OpenSpec, git, the GitHub CLI, the toolchain and the targets of each platform's stack profile), the skills that /relevo:think invokes, the extra skills of each role, relevo.yml, labels and branches, branch protection and approvals, the automation secret and workflows, what the ship workflow needs, in a web session whether the repository installs OpenSpec at its start, and a CI check. Read-only: it never changes anything. Use when asked to check, diagnose or verify the Relevo setup.
|
|
4
4
|
argument-hint: ""
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -21,7 +21,8 @@ Scripts live in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `node`. They hol
|
|
|
21
21
|
1. Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/doctor.mjs` from the repository root. It finds the repository itself; pass `--repo owner/name` only when it cannot.
|
|
22
22
|
2. Show its output as it is: each check is `pass`, `FAIL` or `warn`, and each failure and warning has its fix on the next line.
|
|
23
23
|
3. If there are failures, say which command fixes most of them: `/relevo:init` for labels, branches, protection, workflows and the ignore line; the install commands shown for tools and skills; adding the model credential to the repository secrets by hand.
|
|
24
|
-
4. If the
|
|
24
|
+
4. If a `ship.app` check fails, say that the `relevo:ship` label cannot ship in this repository until a GitHub App and its two secrets exist, because the base branch requires checks, and that `/relevo:ship` still works meanwhile.
|
|
25
|
+
5. If the output says branch protection is not available, say plainly that approving and merging are then enforced only by the written confirmation `ship` asks for, not by the platform.
|
|
25
26
|
|
|
26
27
|
## Exit status
|
|
27
28
|
|