@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
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Says whether the working tree may switch branches: a dev server of the address under review that runs from it
|
|
3
|
+
// would lose its files. Usage:
|
|
4
|
+
// node scripts/dev-server-guard.mjs --url http://localhost:5173
|
|
5
|
+
// Prints { code, servers, reason } as JSON.
|
|
6
|
+
// exit 0: no server of that address runs from the working tree exit 1: one does (stop it before switching)
|
|
7
|
+
// exit 3: it cannot tell (no lsof, or the folder cannot be read): do not switch exit 2: bad arguments
|
|
8
|
+
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { guard } from './lib/dev-server-guard.mjs';
|
|
12
|
+
|
|
13
|
+
const i = process.argv.indexOf('--url');
|
|
14
|
+
const url = i === -1 ? undefined : process.argv[i + 1];
|
|
15
|
+
try {
|
|
16
|
+
if (!url || !/^https?:$/.test(new URL(url).protocol)) throw new Error();
|
|
17
|
+
} catch {
|
|
18
|
+
console.error('usage: dev-server-guard.mjs --url <http address>');
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
22
|
+
if (top.status !== 0) {
|
|
23
|
+
console.error('not inside a git repository');
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const lsof = (args) => {
|
|
27
|
+
const r = spawnSync('lsof', args, { encoding: 'utf8' });
|
|
28
|
+
return { status: r.error ? 127 : r.status ?? 1, stdout: r.stdout ?? '' };
|
|
29
|
+
};
|
|
30
|
+
const result = guard({ url, repoRoot: top.stdout.trim() }, { lsof, realpath: realpathSync });
|
|
31
|
+
console.log(JSON.stringify(result));
|
|
32
|
+
process.exit(result.code);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts, checks and stops the dev servers the agent needs for lines that require another configuration. Usage:
|
|
3
|
+
// node scripts/extra-server.mjs start --port 5174 [--set NAME=value ...] [--unset NAME ...] -- npx vite --port 5174 --strictPort
|
|
4
|
+
// node scripts/extra-server.mjs check --port 5174 after web-preflight.mjs --wait: the port is served by that server
|
|
5
|
+
// node scripts/extra-server.mjs stop --port 5174 | --all
|
|
6
|
+
// Each server runs in a process group of its own, logs to evidence/servers/<port>.log and is recorded in
|
|
7
|
+
// evidence/servers.json (override the folder with --dir). Only recorded servers are ever stopped, so the person's own
|
|
8
|
+
// server is never touched. The environment files are never read; their names only decide whether --unset can work.
|
|
9
|
+
// exit 0: done exit 1: not recorded, or the port is not served by the recorded server (it was stopped)
|
|
10
|
+
// exit 2: bad arguments exit 3: the port is busy exit 4: --unset with environment files present exit 5: no lsof
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { checkServer, parseSet, startServer, stopServers } from './lib/extra-server.mjs';
|
|
16
|
+
|
|
17
|
+
const dash = process.argv.indexOf('--');
|
|
18
|
+
const argv = dash === -1 ? process.argv.slice(2) : process.argv.slice(2, dash);
|
|
19
|
+
const command = dash === -1 ? [] : process.argv.slice(dash + 1);
|
|
20
|
+
const [action] = argv;
|
|
21
|
+
const values = (name) => argv.flatMap((a, i) => (a === `--${name}` ? [argv[i + 1]] : []));
|
|
22
|
+
const usage = (message) => {
|
|
23
|
+
if (message) console.error(message);
|
|
24
|
+
console.error('usage: extra-server.mjs start --port <p> [--set NAME=value] [--unset NAME] -- <command>\n extra-server.mjs check --port <p>\n extra-server.mjs stop --port <p> | --all');
|
|
25
|
+
process.exit(2);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const dir = values('dir')[0] ?? 'evidence';
|
|
29
|
+
const file = join(dir, 'servers.json');
|
|
30
|
+
const port = values('port')[0] === undefined ? null : Number(values('port')[0]);
|
|
31
|
+
const lsof = (args) => {
|
|
32
|
+
const r = spawnSync('lsof', args, { encoding: 'utf8' });
|
|
33
|
+
return { status: r.error ? 127 : r.status ?? 1, stdout: r.stdout ?? '' };
|
|
34
|
+
};
|
|
35
|
+
const deps = {
|
|
36
|
+
lsof,
|
|
37
|
+
dir,
|
|
38
|
+
baseEnv: process.env,
|
|
39
|
+
listDir: () => readdirSync(process.cwd()),
|
|
40
|
+
load: () => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : []),
|
|
41
|
+
save: (records) => {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(records, null, 2)}\n`);
|
|
44
|
+
},
|
|
45
|
+
spawn: (cmd, env, log) => {
|
|
46
|
+
mkdirSync(join(dir, 'servers'), { recursive: true });
|
|
47
|
+
const fd = openSync(log, 'a');
|
|
48
|
+
const child = spawn(cmd[0], cmd.slice(1), { detached: true, stdio: ['ignore', fd, fd], env });
|
|
49
|
+
child.unref();
|
|
50
|
+
return child.pid;
|
|
51
|
+
},
|
|
52
|
+
pgidOf: (pid) => Number(spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim()),
|
|
53
|
+
kill: (pgid) => {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(-pgid, 'SIGTERM');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ESRCH') throw error;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let result;
|
|
63
|
+
try {
|
|
64
|
+
if (action === 'start') {
|
|
65
|
+
if (port === null) usage('start needs --port');
|
|
66
|
+
result = startServer({ port, set: parseSet(values('set')), unset: values('unset'), command }, deps);
|
|
67
|
+
} else if (action === 'check') {
|
|
68
|
+
if (port === null) usage('check needs --port');
|
|
69
|
+
result = checkServer({ port }, deps);
|
|
70
|
+
} else if (action === 'stop') {
|
|
71
|
+
if (port === null && !argv.includes('--all')) usage('stop needs --port or --all');
|
|
72
|
+
result = stopServers({ port, all: argv.includes('--all') }, deps);
|
|
73
|
+
} else usage();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
usage(error.message);
|
|
76
|
+
}
|
|
77
|
+
(result.code === 0 ? console.log : console.error)(result.message);
|
|
78
|
+
process.exit(result.code);
|
package/scripts/init.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
11
11
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { dirname, resolve } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { gatherFacts, readOnlyGh, WORKFLOW_FILES } from './lib/doctor.mjs';
|
|
14
|
+
import { gatherFacts, readOnlyGh, vueWebFacts, WORKFLOW_FILES } from './lib/doctor.mjs';
|
|
15
15
|
import { applyPlan, planInit, renderPlan, skipItems } from './lib/init.mjs';
|
|
16
16
|
|
|
17
17
|
const arg = (name) => {
|
|
@@ -50,6 +50,8 @@ const facts = gatherFacts({
|
|
|
50
50
|
repo,
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
facts.vueWeb = vueWebFacts(facts.config?.platforms ?? answers.platforms ?? {}, { tool, exists: existsSync });
|
|
54
|
+
|
|
53
55
|
let admin = false;
|
|
54
56
|
try {
|
|
55
57
|
admin = JSON.parse(run('gh', ['api', `repos/${repo}`])).permissions?.admin === true;
|
|
@@ -14,8 +14,11 @@ const REASON = {
|
|
|
14
14
|
'qa-failed': 'a branch already exists and the text would no longer match what was built',
|
|
15
15
|
'changes-requested': 'a branch already exists and the text would no longer match what was built',
|
|
16
16
|
'needs-reviewer': 'it is built and waiting for a reviewer',
|
|
17
|
+
ship: 'its pull request is being merged',
|
|
18
|
+
// Not a state: it travels next to one, so the state decides whether the text may change.
|
|
19
|
+
urgent: 'the label asks for an early start and does not commit the issue to a build',
|
|
17
20
|
};
|
|
18
|
-
export const EDITABLE_LABELS = ['needs-spec'];
|
|
21
|
+
export const EDITABLE_LABELS = ['needs-spec', 'urgent'];
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* @param {{state?: string, labels?: Array<string|{name: string}>}} issue `gh issue view --json state,labels`
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export const SETTINGS = [
|
|
|
20
20
|
{ path: 'review.merger', type: 'enum', allowed: ['author', 'approver'], default: 'approver' },
|
|
21
21
|
{ path: 'review.autopilot', type: 'enum', allowed: ['off', 'precheck', 'verify'], default: 'off' },
|
|
22
22
|
{ path: 'review.allowed_origins', type: 'origin-list', default: [] },
|
|
23
|
+
// Whether the ship workflow may merge when no check ran on any commit; /relevo:ship asks the person instead.
|
|
24
|
+
{ path: 'review.ship_without_ci', type: 'bool', default: false },
|
|
23
25
|
{ path: 'limits.built_unreviewed_per_person', type: 'int', min: 1, default: 2 },
|
|
24
26
|
{ path: 'limits.concurrent_agents', type: 'int', min: 1, default: 3 },
|
|
25
27
|
{ path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
|
|
@@ -29,6 +31,8 @@ export const SETTINGS = [
|
|
|
29
31
|
{ path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true, default: null, requiredFor: 'claude', missing: 'unattended runs must always have a cost limit (amount in US dollars per run)', rejectedFor: 'antigravity', rejection: 'the agent has no cost limit; set limits.run_max_tokens instead, and note that the quota of the Google Cloud project that owns the key is the ceiling that really holds' },
|
|
30
32
|
{ path: 'limits.run_max_tokens', type: 'int', min: 1000, default: null, required: true, requiredFor: 'antigravity', missing: 'unattended runs must always have a limit, and for this agent it is a number of tokens per run', rejectedFor: 'claude', rejection: 'the run is limited by limits.run_max_cost' },
|
|
31
33
|
{ path: 'agent.build', type: 'enum', allowed: ['claude', 'antigravity'], default: 'claude' },
|
|
34
|
+
// The runner of every Relevo workflow: runs-on is read before any step, so init writes these labels into the files.
|
|
35
|
+
{ path: 'runner.labels', type: 'label-list', default: ['ubuntu-latest'] },
|
|
32
36
|
{ path: 'qa.retries', type: 'int', min: 0, default: 1 },
|
|
33
37
|
{ path: 'qa.rules_from', type: 'path-list', default: [] },
|
|
34
38
|
{ path: 'skills.allow_scripts', type: 'string[]', default: [] },
|
|
@@ -79,6 +83,15 @@ function checkType(setting, value, errors) {
|
|
|
79
83
|
errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
|
|
80
84
|
}
|
|
81
85
|
break;
|
|
86
|
+
case 'label-list':
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
errors.push({ path, message: `${path} must be a list of runner labels, for example [self-hosted, Front] (got ${JSON.stringify(value)})` });
|
|
89
|
+
} else if (value.length === 0) {
|
|
90
|
+
errors.push({ path, message: `${path} needs at least one label; leave the setting out to use ubuntu-latest` });
|
|
91
|
+
} else if (value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
92
|
+
errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
82
95
|
case 'path-list':
|
|
83
96
|
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
84
97
|
errors.push({ path, message: `${path} must be a file name or a list of file names (got ${JSON.stringify(value)})` });
|
|
@@ -161,7 +174,12 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
|
|
|
161
174
|
}
|
|
162
175
|
const known = new Set(SETTINGS.filter((s) => s.path.startsWith(`${group}.`)).map((s) => s.path.split('.')[1]));
|
|
163
176
|
Object.keys(value).forEach((k) => {
|
|
164
|
-
if (
|
|
177
|
+
if (known.has(k)) return;
|
|
178
|
+
// Relevo cannot verify what a runner is, so it offers no such key: a runner is assumed to persist.
|
|
179
|
+
const message = `${group}.${k}` === 'runner.ephemeral'
|
|
180
|
+
? 'unknown setting "runner.ephemeral": Relevo does not ask whether a runner is ephemeral, it assumes every runner persists between jobs'
|
|
181
|
+
: `unknown setting "${group}.${k}"`;
|
|
182
|
+
errors.push({ path: `${group}.${k}`, message });
|
|
165
183
|
});
|
|
166
184
|
});
|
|
167
185
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Which processes listen on a port and where they run from, through lsof. A review does not switch branches under a
|
|
2
|
+
// dev server that runs from the working tree, because the server would lose its files.
|
|
3
|
+
// Implements "Review restores the base branch upon completion" of openspec/specs/review/spec.md.
|
|
4
|
+
|
|
5
|
+
import { sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** The pids listening on a TCP port; null when lsof cannot be run. `lsof(args)` returns { status, stdout }. */
|
|
8
|
+
export function listeners(port, lsof) {
|
|
9
|
+
const r = lsof(['-nP', `-tiTCP:${port}`, '-sTCP:LISTEN']);
|
|
10
|
+
if (r.status === 127) return null;
|
|
11
|
+
// lsof exits 1 with nothing printed when no process matches.
|
|
12
|
+
return String(r.stdout ?? '').split('\n').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The current folder of a process; null when lsof cannot tell. */
|
|
16
|
+
export function cwdOf(pid, lsof) {
|
|
17
|
+
const r = lsof(['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
|
|
18
|
+
if (r.status !== 0) return null;
|
|
19
|
+
const line = String(r.stdout ?? '').split('\n').find((l) => l.startsWith('n'));
|
|
20
|
+
return line ? line.slice(1) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The port of an http address, with the default of its scheme. */
|
|
24
|
+
export function portOf(url) {
|
|
25
|
+
const u = new URL(url);
|
|
26
|
+
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* May the working tree switch branches? Looks at the processes listening on the port of `url`.
|
|
31
|
+
* Returns { code, servers }: 0 none runs from the repository, 1 one does, 3 it cannot tell.
|
|
32
|
+
* deps: lsof(args) -> { status, stdout }, realpath(path) -> path.
|
|
33
|
+
*/
|
|
34
|
+
export function guard({ url, repoRoot }, { lsof, realpath }) {
|
|
35
|
+
const pids = listeners(portOf(url), lsof);
|
|
36
|
+
if (pids === null) return { code: 3, servers: [], reason: 'lsof is not available, so the folder of the server cannot be known' };
|
|
37
|
+
const root = realpath(repoRoot);
|
|
38
|
+
const servers = pids.map((pid) => {
|
|
39
|
+
const cwd = cwdOf(pid, lsof);
|
|
40
|
+
let real = null;
|
|
41
|
+
try {
|
|
42
|
+
real = cwd ? realpath(cwd) : null;
|
|
43
|
+
} catch { /* a folder that no longer exists is outside the repository */ }
|
|
44
|
+
return { pid, cwd, inside: real !== null && (real === root || real.startsWith(`${root}${sep}`)) };
|
|
45
|
+
});
|
|
46
|
+
if (servers.some((s) => s.inside)) return { code: 1, servers, reason: 'a server of the address under review runs from the working tree' };
|
|
47
|
+
if (servers.some((s) => s.cwd === null)) return { code: 3, servers, reason: 'the folder of a server on that port cannot be read' };
|
|
48
|
+
return { code: 0, servers, reason: null };
|
|
49
|
+
}
|
package/scripts/lib/doctor.mjs
CHANGED
|
@@ -25,7 +25,10 @@ export const SKILL_INSTALL = {
|
|
|
25
25
|
'supabase-postgres-best-practices': 'run `npx skills add supabase/agent-skills --skill supabase-postgres-best-practices`',
|
|
26
26
|
};
|
|
27
27
|
export const OPENSPEC_INSTALL = 'npm install --global @fission-ai/openspec (needs Node.js 20.19 or newer)';
|
|
28
|
-
export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml'];
|
|
28
|
+
export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml', 'relevo-ship.yml'];
|
|
29
|
+
// The repository variable that pauses the ship workflow, and the secrets of the GitHub App it pushes with.
|
|
30
|
+
export const SHIP_VARIABLE = 'RELEVO_CLOUD_SHIP';
|
|
31
|
+
export const SHIP_APP_SECRETS = ['RELEVO_SHIP_APP_ID', 'RELEVO_SHIP_APP_KEY'];
|
|
29
32
|
const REQUIRED_WORKFLOWS = new Set(['relevo-go.yml']);
|
|
30
33
|
// How to ask a tool that it is there (most answer --version), and how to install the ones Relevo knows.
|
|
31
34
|
const TOOL_PROBE = { adb: ['version'] };
|
|
@@ -70,10 +73,10 @@ export function refMatches(pattern, branch, defaultBranch) {
|
|
|
70
73
|
|
|
71
74
|
/**
|
|
72
75
|
* What the repository enforces on a branch, from its rulesets and its classic protection.
|
|
73
|
-
* Returns { protected, pullRequestRequired, approvals, dismissStale, blocksDeletion, blocksForcePush }.
|
|
76
|
+
* Returns { protected, pullRequestRequired, approvals, dismissStale, lastPushApproval, requiredChecks, blocksDeletion, blocksForcePush }.
|
|
74
77
|
*/
|
|
75
78
|
export function summarizeProtection({ rulesets = [], classic = null, branch, defaultBranch }) {
|
|
76
|
-
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, blocksDeletion: false, blocksForcePush: false };
|
|
79
|
+
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, lastPushApproval: false, requiredChecks: false, blocksDeletion: false, blocksForcePush: false };
|
|
77
80
|
rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active').forEach((r) => {
|
|
78
81
|
const include = r.conditions?.ref_name?.include ?? [];
|
|
79
82
|
const exclude = r.conditions?.ref_name?.exclude ?? [];
|
|
@@ -84,7 +87,9 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
84
87
|
out.pullRequestRequired = true;
|
|
85
88
|
out.approvals = Math.max(out.approvals, rule.parameters?.required_approving_review_count ?? 0);
|
|
86
89
|
if (rule.parameters?.dismiss_stale_reviews_on_push) out.dismissStale = true;
|
|
90
|
+
if (rule.parameters?.require_last_push_approval) out.lastPushApproval = true;
|
|
87
91
|
}
|
|
92
|
+
if (rule.type === 'required_status_checks') out.requiredChecks = true;
|
|
88
93
|
if (rule.type === 'deletion') out.blocksDeletion = true;
|
|
89
94
|
if (rule.type === 'non_fast_forward') out.blocksForcePush = true;
|
|
90
95
|
});
|
|
@@ -96,13 +101,45 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
96
101
|
out.pullRequestRequired = true;
|
|
97
102
|
out.approvals = Math.max(out.approvals, pr.required_approving_review_count ?? 0);
|
|
98
103
|
if (pr.dismiss_stale_reviews) out.dismissStale = true;
|
|
104
|
+
if (pr.require_last_push_approval) out.lastPushApproval = true;
|
|
99
105
|
}
|
|
106
|
+
if (classic.required_status_checks) out.requiredChecks = true;
|
|
100
107
|
if (classic.allow_deletions?.enabled === false) out.blocksDeletion = true;
|
|
101
108
|
if (classic.allow_force_pushes?.enabled === false) out.blocksForcePush = true;
|
|
102
109
|
}
|
|
103
110
|
return out;
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
// ---------- Playwright of a platform
|
|
114
|
+
|
|
115
|
+
/** Is the platform a Vue platform reviewed in a browser? `web` is the only target of Vue, and its default. */
|
|
116
|
+
export const isVueWeb = (platform) => platform?.stack === 'vue' && (!platform.targets?.length || platform.targets.includes('web'));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does Playwright resolve from the folder of a platform, and is its Chromium installed? Resolving from the folder, not
|
|
120
|
+
* from where the command runs, finds the node_modules of a platform in a subfolder. `tool(name, args)` throws when the
|
|
121
|
+
* command fails; `exists(path)`.
|
|
122
|
+
*/
|
|
123
|
+
export function playwrightFacts(dir, { tool, exists }) {
|
|
124
|
+
const where = "require.resolve('playwright', { paths: [require('node:path').resolve(process.argv[1])] })";
|
|
125
|
+
try {
|
|
126
|
+
tool('node', ['-e', where, dir || '.']);
|
|
127
|
+
} catch {
|
|
128
|
+
return { playwright: false, chromium: false };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const path = String(tool('node', ['-e', `process.stdout.write(require(${where}).chromium.executablePath())`, dir || '.'])).trim();
|
|
132
|
+
return { playwright: true, chromium: path !== '' && Boolean(exists(path)) };
|
|
133
|
+
} catch {
|
|
134
|
+
return { playwright: true, chromium: false };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** playwrightFacts of each Vue platform with a web target, by platform name. */
|
|
139
|
+
export function vueWebFacts(platforms, deps) {
|
|
140
|
+
return Object.fromEntries(Object.entries(platforms ?? {}).filter(([, p]) => isVueWeb(p)).map(([name, p]) => [name, playwrightFacts(p.path, deps)]));
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
// ---------- gather
|
|
107
144
|
|
|
108
145
|
/**
|
|
@@ -160,6 +197,8 @@ export function gatherFacts(p) {
|
|
|
160
197
|
f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
|
|
161
198
|
f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
|
|
162
199
|
f.pluginDeclared = pluginDeclaration(p);
|
|
200
|
+
f.webHook = webHookDeclaration(p);
|
|
201
|
+
f.remoteSession = process.env.CLAUDE_CODE_REMOTE === 'true';
|
|
163
202
|
|
|
164
203
|
// Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
|
|
165
204
|
// repository is known they are read from there. `present` says whether the file is where it has to be; `local`
|
|
@@ -210,6 +249,7 @@ export function gatherFacts(p) {
|
|
|
210
249
|
f.canApprove = tryRun(() => api(`repos/${f.repo}/actions/permissions/workflow`).can_approve_pull_request_reviews, null);
|
|
211
250
|
f.secrets = tryRun(() => api(`repos/${f.repo}/actions/secrets`).secrets.map((s) => s.name));
|
|
212
251
|
f.cloudVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${CLOUD_VARIABLE}`).value, null);
|
|
252
|
+
f.shipVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${SHIP_VARIABLE}`).value, null);
|
|
213
253
|
// The repositories this one may depend on: can they be read, and are they private (then a token is needed)?
|
|
214
254
|
f.links = (f.config?.links ?? []).map((link) => {
|
|
215
255
|
const info = tryRun(() => api(`repos/${link}`), null);
|
|
@@ -286,6 +326,28 @@ export function pluginDeclaration(p) {
|
|
|
286
326
|
}
|
|
287
327
|
}
|
|
288
328
|
|
|
329
|
+
// The one-line `SessionStart` hook that installs OpenSpec in a session of Claude Code on the web. It does nothing
|
|
330
|
+
// outside a remote session, and nothing when `openspec` already runs; `|| true` keeps a failed install from failing the hook.
|
|
331
|
+
export const WEB_HOOK_COMMAND = '[ "$CLAUDE_CODE_REMOTE" = "true" ] && ! command -v openspec >/dev/null 2>&1 && OPENSPEC_TELEMETRY=0 npm install --global @fission-ai/openspec || true';
|
|
332
|
+
|
|
333
|
+
/** Does a `SessionStart` entry install OpenSpec? The package name is what is matched, so a hook written by hand counts. */
|
|
334
|
+
const installsOpenspec = (entry) => (entry?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec'));
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Whether the committed settings file has a `SessionStart` hook that installs OpenSpec: { declared } or, when the file
|
|
338
|
+
* is not valid JSON, { unreadable: true }. A missing file is { declared: false }.
|
|
339
|
+
*/
|
|
340
|
+
export function webHookDeclaration(p) {
|
|
341
|
+
const path = '.claude/settings.json';
|
|
342
|
+
if (!p.exists(path)) return { declared: false };
|
|
343
|
+
try {
|
|
344
|
+
const settings = JSON.parse(p.read(path));
|
|
345
|
+
return { declared: Array.isArray(settings?.hooks?.SessionStart) && settings.hooks.SessionStart.some(installsOpenspec) };
|
|
346
|
+
} catch {
|
|
347
|
+
return { unreadable: true };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
289
351
|
/** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
|
|
290
352
|
function readerFor(p, tryRun) {
|
|
291
353
|
const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
|
|
@@ -363,7 +425,7 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
363
425
|
const pubspec = p.exists(join(platform.path, 'pubspec.yaml')) ? tryRun(() => p.read(join(platform.path, 'pubspec.yaml')), '') : '';
|
|
364
426
|
entry.driverDependency = /^\s*flutter_driver\s*:/m.test(pubspec ?? '');
|
|
365
427
|
} else if (platform.stack === 'vue') {
|
|
366
|
-
entry.playwright =
|
|
428
|
+
entry.playwright = playwrightFacts(platform.path, { tool: p.tool, exists: p.exists }).playwright;
|
|
367
429
|
}
|
|
368
430
|
return entry;
|
|
369
431
|
}),
|
|
@@ -532,7 +594,7 @@ function platformChecks(f) {
|
|
|
532
594
|
if (!e.driverDependency) r.push(warn(`autopilot.${e.name}.dependency`, `platform ${e.name} does not depend on flutter_driver (dev_dependencies), which the driven review needs`, 'add flutter_driver: { sdk: flutter } under dev_dependencies in pubspec.yaml'));
|
|
533
595
|
else r.push(pass(`autopilot.${e.name}.dependency`, `platform ${e.name} depends on flutter_driver`));
|
|
534
596
|
} else if (e.stack === 'vue') {
|
|
535
|
-
if (!e.playwright) r.push(warn(`autopilot.${e.name}.playwright`, `review.autopilot is ${f.autopilot.mode} but Playwright cannot be resolved from this project (platform ${e.name})`, 'npm install -D playwright && npx playwright install chromium
|
|
597
|
+
if (!e.playwright) r.push(warn(`autopilot.${e.name}.playwright`, `review.autopilot is ${f.autopilot.mode} but Playwright cannot be resolved from this project (platform ${e.name})`, `run /relevo:init, which offers it, or \`${e.path && e.path !== '.' ? `cd ${e.path} && ` : ''}npm install -D playwright && npx playwright install chromium\` and commit package.json and the lock file`));
|
|
536
598
|
else r.push(pass(`autopilot.${e.name}.playwright`, `Playwright is available for platform ${e.name}`));
|
|
537
599
|
} else {
|
|
538
600
|
r.push(warn(`autopilot.${e.name}.adapter`, `review.autopilot is ${f.autopilot.mode}, but a driven review of the stack ${e.stack} (platform ${e.name}) is not available`, 'set review.autopilot to off, or review that platform by hand'));
|
|
@@ -576,6 +638,7 @@ export function runChecks(f) {
|
|
|
576
638
|
else r.push(fail('openspec.root', 'the OpenSpec root is missing or not tracked by git, so a clean checkout (and the runner) has no `openspec/`', 'run /relevo:init, then commit openspec/config.yaml and the .gitkeep files'));
|
|
577
639
|
r.push(f.evidenceIgnored ? pass('evidence.ignored', 'evidence/ is ignored by git') : fail('evidence.ignored', 'evidence/ is not in .gitignore; `review` would put screenshots in git', 'add the line `evidence/` to .gitignore (or run /relevo:init)'));
|
|
578
640
|
r.push(...pluginDeclaredChecks(f));
|
|
641
|
+
r.push(...webHookChecks(f));
|
|
579
642
|
|
|
580
643
|
// GitHub side
|
|
581
644
|
if (!f.repo || !f.ghAuth) {
|
|
@@ -602,6 +665,7 @@ export function runChecks(f) {
|
|
|
602
665
|
else if (f.config?.review?.mode !== 'none' && p.approvals < 1) r.push(fail(`repo.protection.${key}`, `${name} requires a pull request but no approval`, `require at least 1 approving review on ${name}`));
|
|
603
666
|
else r.push(pass(`repo.protection.${key}`, `${name} requires a pull request${p.approvals ? ` with ${p.approvals} approval(s)` : ''}`));
|
|
604
667
|
if (p?.dismissStale) r.push(fail(`repo.dismiss-stale.${key}`, `${name} dismisses approvals when new commits are pushed; the spec archive commit pushed by \`ship\` would dismiss the approval`, `turn off "dismiss stale pull request approvals when new commits are pushed" on ${name}`));
|
|
668
|
+
if (p?.lastPushApproval) r.push(fail(`repo.last-push.${key}`, `${name} requires the most recent push to be approved by someone else; the spec archive commit pushed by \`ship\` would need a new approval`, `turn off "require approval of the most recent reviewable push" on ${name}`));
|
|
605
669
|
});
|
|
606
670
|
}
|
|
607
671
|
if (!f.protectionUnavailable && !f.protectionError) {
|
|
@@ -637,11 +701,13 @@ export function runChecks(f) {
|
|
|
637
701
|
|
|
638
702
|
r.push(...pluginInstallChecks(f));
|
|
639
703
|
r.push(...toolchainChecks(f));
|
|
704
|
+
r.push(...runnerChecks(f));
|
|
640
705
|
if (f.pluginMarketplace && f.pluginRepoPrivate === true && !f.pluginMarketplaceHasToken) {
|
|
641
706
|
r.push(fail('automation.plugin-clone', `the plugin repository (${f.pluginMarketplace}) is private, so the runner cannot clone it without a credential`, 'store a read-only token as RELEVO_PLUGIN_TOKEN and use the private-repository line in relevo-go.yml'));
|
|
642
707
|
}
|
|
643
708
|
r.push(...linkChecks(f));
|
|
644
709
|
r.push(...executionChecks(f));
|
|
710
|
+
r.push(...shipChecks(f));
|
|
645
711
|
if (f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
|
|
646
712
|
if (f.secrets.includes('RELEVO_PLUGIN_TOKEN')) r.push(pass('automation.plugin-secret', 'the RELEVO_PLUGIN_TOKEN secret exists for the private plugin repository'));
|
|
647
713
|
else r.push(fail('automation.plugin-secret', 'the plugin repository is private and the repository has no RELEVO_PLUGIN_TOKEN secret, so the workflows fall back to the default token and fail at checkout', 'store a read-only fine-grained token that can read the plugin repository as the repository secret RELEVO_PLUGIN_TOKEN; a secret defined at organization level is not visible to this check'));
|
|
@@ -649,6 +715,27 @@ export function runChecks(f) {
|
|
|
649
715
|
return r;
|
|
650
716
|
}
|
|
651
717
|
|
|
718
|
+
/**
|
|
719
|
+
* What the ship workflow needs, when it is on the default branch: the App secrets when the base branch requires checks
|
|
720
|
+
* (a push with the workflow token starts none, so the archive commit would never get them), and whether it is paused.
|
|
721
|
+
* Its label is checked with the other labels, and it needs no model credential.
|
|
722
|
+
*/
|
|
723
|
+
function shipChecks(f) {
|
|
724
|
+
const r = [];
|
|
725
|
+
if (!f.workflows?.['relevo-ship.yml']?.present) return r;
|
|
726
|
+
const base = f.config?.branches?.base ?? 'base';
|
|
727
|
+
if (f.protectionError || f.protectionUnavailable) return r;
|
|
728
|
+
if (f.protection?.base?.requiredChecks) {
|
|
729
|
+
if (f.secrets === null) r.push(warn('ship.app', `${base} requires status checks and the secrets could not be listed, so the ship App was not checked`, `check that ${SHIP_APP_SECRETS.join(' and ')} exist`));
|
|
730
|
+
else if (SHIP_APP_SECRETS.every((s) => f.secrets.includes(s))) r.push(pass('ship.app', `${base} requires status checks and the ship App secrets exist, so the relevo:ship label pushes with the App and the checks run on the archive commit`));
|
|
731
|
+
else r.push(fail('ship.app', `${base} requires status checks, so the relevo:ship label cannot ship here: the archive commit pushed with the workflow token would never get its checks`, `create a GitHub App for this repository and store its id and private key as ${SHIP_APP_SECRETS.join(' and ')} (docs/execution.md, "Shipping with a label"), or ship with /relevo:ship`));
|
|
732
|
+
} else {
|
|
733
|
+
r.push(pass('ship.app', `${base} requires no status checks, so the checks of the approved head carry over to the archive commit and the ship workflow needs no App`));
|
|
734
|
+
}
|
|
735
|
+
if (String(f.shipVariable ?? '').trim().toLowerCase() === 'off') r.push(warn('ship.paused', `the relevo:ship label is paused (${SHIP_VARIABLE} is off)`, `delete the repository variable ${SHIP_VARIABLE}, or set it to on, to ship with the label again`));
|
|
736
|
+
return r;
|
|
737
|
+
}
|
|
738
|
+
|
|
652
739
|
// The action that sets up the toolchain of a stack in the build job of the go workflow. Vue needs only Node, which the
|
|
653
740
|
// job installs for Relevo itself, and a stack that is not listed here is not judged.
|
|
654
741
|
const TOOLCHAIN_ACTIONS = { flutter: { action: 'subosito/flutter-action', step: 'the Flutter step' }, laravel: { action: 'shivammathur/setup-php', step: 'the Laravel step' } };
|
|
@@ -670,6 +757,38 @@ function toolchainChecks(f) {
|
|
|
670
757
|
});
|
|
671
758
|
}
|
|
672
759
|
|
|
760
|
+
const HOSTED_RUNNER = ['ubuntu-latest'];
|
|
761
|
+
|
|
762
|
+
/** The labels written in a `runs-on` line: `[self-hosted, Front]` or `ubuntu-latest`; null for an expression we cannot judge. */
|
|
763
|
+
function parseRunsOn(value) {
|
|
764
|
+
if (value.includes('${{')) return null;
|
|
765
|
+
const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
|
|
766
|
+
return inner.split(',').map((l) => l.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Does every Relevo workflow run on the runner that relevo.yml names (`runner.labels`, else ubuntu-latest)? A workflow
|
|
771
|
+
* edited by hand to another runner is reported with the file, because the next copy of the template would undo it. A
|
|
772
|
+
* runner that is not GitHub's is only assumed to persist: Relevo cannot tell, and says so instead of claiming.
|
|
773
|
+
*/
|
|
774
|
+
function runnerChecks(f) {
|
|
775
|
+
const wanted = f.config?.runner?.labels ?? HOSTED_RUNNER;
|
|
776
|
+
const same = (a, b) => a.length === b.length && [...a].sort().every((l, i) => l === [...b].sort()[i]);
|
|
777
|
+
const r = [];
|
|
778
|
+
let judged = 0;
|
|
779
|
+
Object.entries(f.workflows ?? {}).forEach(([file, w]) => {
|
|
780
|
+
if (!w.present || typeof w.text !== 'string') return;
|
|
781
|
+
judged += 1;
|
|
782
|
+
const found = [...w.text.matchAll(/^[ \t]+runs-on:[ \t]*(.+?)[ \t]*(?:#.*)?$/gm)].map((m) => parseRunsOn(m[1])).filter((l) => l !== null);
|
|
783
|
+
const other = found.find((labels) => !same(labels, wanted));
|
|
784
|
+
if (other) r.push(fail(`automation.runner.${file}`, `${file} runs on [${other.join(', ')}] and relevo.yml says [${wanted.join(', ')}]`, same(wanted, HOSTED_RUNNER) ? `set runner.labels: [${other.join(', ')}] in relevo.yml if that is the runner you want, or restore runs-on: ubuntu-latest in ${file}` : `change runs-on in ${file} to [${wanted.join(', ')}], or change runner.labels in relevo.yml`));
|
|
785
|
+
});
|
|
786
|
+
if (r.length > 0) return r;
|
|
787
|
+
if (judged > 0) r.push(pass('automation.runner', `the Relevo workflows run on [${wanted.join(', ')}]`));
|
|
788
|
+
if (!same(wanted, HOSTED_RUNNER)) r.push(warn('automation.runner.persistence', `the runner [${wanted.join(', ')}] is not GitHub's: Relevo assumes it keeps its files between jobs, and the code the agent writes runs with the permissions of the runner's user`, 'an ephemeral runner (created for one job) or a container is the real barrier; Relevo installs its tools under the temporary folder of the job and removes what it adds, but cannot make the machine a sandbox (docs/security.md)'));
|
|
789
|
+
return r;
|
|
790
|
+
}
|
|
791
|
+
|
|
673
792
|
/** Does the committed settings file declare the plugin? It is a local fact, so it needs no GitHub sign-in. */
|
|
674
793
|
function pluginDeclaredChecks(f) {
|
|
675
794
|
const r = [];
|
|
@@ -679,6 +798,18 @@ function pluginDeclaredChecks(f) {
|
|
|
679
798
|
return r;
|
|
680
799
|
}
|
|
681
800
|
|
|
801
|
+
/**
|
|
802
|
+
* Does the repository install OpenSpec in the sessions of Claude Code on the web? The hook has no use on a terminal,
|
|
803
|
+
* where OpenSpec stays installed, so it is judged only when doctor itself runs in a remote session. It warns: the
|
|
804
|
+
* project can run without it, but every new session starts without OpenSpec.
|
|
805
|
+
*/
|
|
806
|
+
function webHookChecks(f) {
|
|
807
|
+
if (!f.remoteSession || !f.webHook) return [];
|
|
808
|
+
if (f.webHook.unreadable) return [warn('web.openspec-hook', '.claude/settings.json is not valid JSON, so it cannot be checked whether it installs OpenSpec in web sessions', 'fix the file; /relevo:init then offers the hook')];
|
|
809
|
+
if (f.webHook.declared) return [pass('web.openspec-hook', 'the repository installs OpenSpec at the start of a web session (SessionStart hook in .claude/settings.json)')];
|
|
810
|
+
return [warn('web.openspec-hook', 'this is a web session and the repository has no SessionStart hook that installs OpenSpec, so every new session starts without it and go cannot run', `run /relevo:init to add the hook, commit .claude/settings.json, or install it now: ${OPENSPEC_INSTALL}`)];
|
|
811
|
+
}
|
|
812
|
+
|
|
682
813
|
/**
|
|
683
814
|
* The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
|
|
684
815
|
* the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Dev servers the agent starts for a line that needs the app with another configuration. Each one runs in a process
|
|
2
|
+
// group of its own on a port that was free, is recorded, and is the only kind of server the agent may stop.
|
|
3
|
+
// Implements "Servers started by the agent are its own" and "Environment files of a reviewed app are never read" of
|
|
4
|
+
// openspec/specs/review/spec.md.
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { listeners } from './dev-server-guard.mjs';
|
|
8
|
+
|
|
9
|
+
export const EXIT = { refused: 1, busy: 3, envFiles: 4, noLsof: 5 };
|
|
10
|
+
|
|
11
|
+
// Example files hold no values the app loads.
|
|
12
|
+
const EXAMPLES = new Set(['.env.example', '.env.sample', '.env.template']);
|
|
13
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
|
|
15
|
+
/** The environment files a dev server loads by itself, by name only: `.env` and `.env.*`, without the examples. */
|
|
16
|
+
export function envFiles(names) {
|
|
17
|
+
return names.filter((n) => /^\.env(\..+)?$/.test(n) && !EXAMPLES.has(n)).sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The environment of the server: the current one with `set` applied and `unset` removed. */
|
|
21
|
+
export function serverEnv(base, { set = {}, unset = [] }) {
|
|
22
|
+
const env = { ...base, ...set };
|
|
23
|
+
unset.forEach((n) => { delete env[n]; });
|
|
24
|
+
return env;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse `NAME=value` pairs of --set; throws on a bad name. */
|
|
28
|
+
export function parseSet(pairs) {
|
|
29
|
+
return Object.fromEntries(pairs.map((p) => {
|
|
30
|
+
const i = p.indexOf('=');
|
|
31
|
+
const name = i === -1 ? p : p.slice(0, i);
|
|
32
|
+
if (i === -1 || !NAME_RE.test(name)) throw new Error(`--set needs NAME=value (got ${p})`);
|
|
33
|
+
return [name, p.slice(i + 1)];
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fail = (code, message) => ({ code, message });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a server. deps: lsof(args), listDir() -> names in the project folder, spawn(command, env, logFile) -> pgid,
|
|
41
|
+
* load() -> records, save(records), baseEnv, dir (the evidence folder, for the log).
|
|
42
|
+
*/
|
|
43
|
+
export function startServer({ port, set = {}, unset = [], command }, deps) {
|
|
44
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) return fail(2, 'the port must be a number between 1024 and 65535');
|
|
45
|
+
if (!command || command.length === 0) return fail(2, 'give the command that starts the server after --');
|
|
46
|
+
const busy = listeners(port, deps.lsof);
|
|
47
|
+
if (busy === null) return fail(EXIT.noLsof, 'lsof is not available, so a free port cannot be told apart: leave this line for the person');
|
|
48
|
+
if (busy.length > 0) return fail(EXIT.busy, `port ${port} is busy (pid ${busy.join(', ')}): choose another; the server already there is not touched`);
|
|
49
|
+
const files = envFiles(deps.listDir());
|
|
50
|
+
if (unset.length > 0 && files.length > 0) {
|
|
51
|
+
return fail(EXIT.envFiles, `a variable cannot be removed while ${files.join(', ')} exist${files.length === 1 ? 's' : ''}: the dev server loads them by itself. Pre-check this line by reading the code, or leave it for the person`);
|
|
52
|
+
}
|
|
53
|
+
const records = deps.load();
|
|
54
|
+
const log = join(deps.dir ?? 'evidence', 'servers', `${port}.log`);
|
|
55
|
+
const pgid = deps.spawn(command, serverEnv(deps.baseEnv, { set, unset }), log);
|
|
56
|
+
records.push({ port, pgid, command: command.join(' '), set: Object.keys(set), unset });
|
|
57
|
+
deps.save(records);
|
|
58
|
+
return { code: 0, message: `started on port ${port} (process group ${pgid}); log in ${log}`, pgid };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Confirm that the process listening on the port belongs to the recorded server; when it does not (the server moved
|
|
63
|
+
* to another port, or failed), stop the recorded group and forget it.
|
|
64
|
+
*/
|
|
65
|
+
export function checkServer({ port }, deps) {
|
|
66
|
+
const records = deps.load();
|
|
67
|
+
const record = records.find((r) => r.port === port);
|
|
68
|
+
if (!record) return fail(EXIT.refused, `no server started by this script is recorded on port ${port}`);
|
|
69
|
+
const pids = listeners(port, deps.lsof) ?? [];
|
|
70
|
+
if (pids.some((pid) => deps.pgidOf(pid) === record.pgid)) return { code: 0, message: `port ${port} is served by the recorded server` };
|
|
71
|
+
deps.kill(record.pgid);
|
|
72
|
+
deps.save(records.filter((r) => r !== record));
|
|
73
|
+
return fail(EXIT.refused, `port ${port} is not served by the server that was started (it may have moved to another port): it was stopped`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Stop recorded servers only: one port, or all of them. A port with no record stops nothing. */
|
|
77
|
+
export function stopServers({ port = null, all = false }, deps) {
|
|
78
|
+
const records = deps.load();
|
|
79
|
+
const chosen = all ? records : records.filter((r) => r.port === port);
|
|
80
|
+
if (!all && chosen.length === 0) return fail(EXIT.refused, `no server started by this script is recorded on port ${port}: nothing was stopped`);
|
|
81
|
+
chosen.forEach((r) => deps.kill(r.pgid));
|
|
82
|
+
deps.save(records.filter((r) => !chosen.includes(r)));
|
|
83
|
+
return { code: 0, message: chosen.length === 0 ? 'no server to stop' : `stopped ${chosen.map((r) => `port ${r.port}`).join(', ')}` };
|
|
84
|
+
}
|