@agent-flow-board/bridge 0.2.0 → 0.4.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/README.md +26 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +274 -0
- package/dist/checks/askBoard.js +143 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/board.js +30 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +126 -87
- package/dist/checks/commands.js +315 -0
- package/dist/checks/connections.js +4 -3
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +655 -0
- package/dist/checks/envPolicy.js +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +28 -4
- package/dist/checks/login.js +307 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +454 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +7 -1
- package/dist/checks/signinKey.js +104 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
package/dist/checks/command.js
CHANGED
|
@@ -4,19 +4,24 @@
|
|
|
4
4
|
// -- DEP-MODULES -- //
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
8
8
|
// -- MODULES -- //
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { ensureBrowser, playwrightRuntimeDir } from './browser.js';
|
|
9
|
+
import { bindFolder } from './bind.js';
|
|
10
|
+
import { ensureBrowser } from './browser.js';
|
|
12
11
|
import { connectChecks, loadRunnerToken, runnerAccount } from './connect.js';
|
|
13
12
|
import { forgetConnection, listConnections, recordConnection } from './connections.js';
|
|
14
|
-
import {
|
|
13
|
+
import { RunnerBoard } from './board.js';
|
|
14
|
+
import { publishSigninKey } from './signinKey.js';
|
|
15
|
+
import { ORG_SLUG, scriptPath, scriptsDir } from './paths.js';
|
|
15
16
|
import { pinFiles, resolveProjectConfig } from './recipe.js';
|
|
16
17
|
import { runCheck } from './run.js';
|
|
17
|
-
import { clearRuns, sweepOrphans } from './scope.js';
|
|
18
|
-
import { permissionFlag } from './sandbox.js';
|
|
19
18
|
import { watchChecks } from './watch.js';
|
|
19
|
+
import { approveCommand, projectCommand, rootsCommand } from './commands.js';
|
|
20
|
+
import { clearCommand, healthCommand } from './doctor.js';
|
|
21
|
+
import { importLegacyCommand } from './importlegacy.js';
|
|
22
|
+
import { loginCommand } from './login.js';
|
|
23
|
+
import { secretsCommand } from './secretscmd.js';
|
|
24
|
+
import { serviceCommand } from './service.js';
|
|
20
25
|
// -- CONSTANTS -- //
|
|
21
26
|
export const CHECK_USAGE = [
|
|
22
27
|
'Usage: agent-flow-board check <command>',
|
|
@@ -25,14 +30,38 @@ export const CHECK_USAGE = [
|
|
|
25
30
|
' pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>] read the board copy of a script; accept it by hash',
|
|
26
31
|
' bind --org <slug> --project <KEY> [--dir <repo>] remember the folder of a board project for "check watch"',
|
|
27
32
|
' watch [--parallel N] [--once] take queued runs from the boards this machine is connected to',
|
|
28
|
-
'
|
|
33
|
+
' run ... also takes [--registration NAME] [--file <script inside the scripts folder>] [--headed]',
|
|
34
|
+
' project <NAME> --org <slug> (--repo <dir> [--app-dir web] [--board-project KEY] [--recipe] | --url <address> [--extra-hosts a,b]) [--adopt-env --adopt-env-approval <code>] [--upload-pictures --pictures-approval <code>] [--approval <code>] | project list|forget|pin',
|
|
35
|
+
' approve --request --action <roots|register|secrets|adopt-env|pictures|login> --target <name> [--why "<sentence>"] [--wait <minutes>] (asks the owner on the board; no terminal)',
|
|
36
|
+
' approve --request-many "<action>:<target>;<action>:<target>" | approve --resume <id,id> [--wait <minutes>] (exit 3 = still waiting)',
|
|
37
|
+
' approve --action <roots|register|secrets|adopt-env|pictures|login> --target <name> (the owner, on a terminal) | roots add|list|remove <dir> [--approval <code>]',
|
|
38
|
+
' secrets import|list|forget --org <slug> --project <NAME> [--from <file> --keys A,B --test-ref <ref> --test-inbox <email> --owner-said "<sentence>" --allow-service-role --approval <code>]',
|
|
39
|
+
' login <NAME> --org <slug> --seeded | service install|uninstall|status [--no-start] | import-legacy --from <afb-checks folder> --org <slug> [--dry-run]',
|
|
40
|
+
' status [--json] | doctor [--fix] [--json] | clear [runs|profiles|auth|dbs|previews|all] [--all] [--yes] | setup | disconnect --server <url> --org <slug>'
|
|
29
41
|
].join('\n');
|
|
30
42
|
// -- FUNCTIONS -- //
|
|
43
|
+
/** The value of --parallel: undefined when the flag is absent (the machine decides), else a whole number from 1 up. */
|
|
44
|
+
export function parallelFlag(raw) {
|
|
45
|
+
if (raw === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
if (!Number.isInteger(n) || n < 1)
|
|
49
|
+
throw new Error(`--parallel takes a whole number of 1 or more, not "${raw.slice(0, 20)}".`);
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
31
52
|
/** Runs one check command and returns the exit code: 0 done, 1 a check failed or a problem was listed, 2 could not run. */
|
|
32
53
|
export async function checkMain(ctx) {
|
|
33
54
|
const { args, log, out } = ctx;
|
|
34
55
|
const sub = args[0];
|
|
35
|
-
|
|
56
|
+
// A flag that is given takes the next word as its value; a missing value, or another flag in its place (--step --json), is an error and never a silent default
|
|
57
|
+
const flag = (name) => {
|
|
58
|
+
if (!args.includes(name))
|
|
59
|
+
return undefined;
|
|
60
|
+
const v = args[args.indexOf(name) + 1];
|
|
61
|
+
if (v === undefined || v.startsWith('--'))
|
|
62
|
+
throw new Error(`Say ${name} <value>.`);
|
|
63
|
+
return v;
|
|
64
|
+
};
|
|
36
65
|
const has = (name) => args.includes(name);
|
|
37
66
|
const json = has('--json');
|
|
38
67
|
const need = (name) => {
|
|
@@ -72,6 +101,10 @@ export async function checkMain(ctx) {
|
|
|
72
101
|
out
|
|
73
102
|
});
|
|
74
103
|
recordConnection({ server: s, org: o, name: done.name });
|
|
104
|
+
// The sign in key of this machine goes to the board with the connection. A board that does not know it yet, or a failure, never stops a connect
|
|
105
|
+
const runnerToken = loadRunnerToken(ctx.store, s, o);
|
|
106
|
+
if (runnerToken)
|
|
107
|
+
await publishSigninKey(new RunnerBoard(s, runnerToken, ctx.fetch)).catch(() => undefined);
|
|
75
108
|
return 0;
|
|
76
109
|
}
|
|
77
110
|
if (sub === 'disconnect') {
|
|
@@ -83,12 +116,27 @@ export async function checkMain(ctx) {
|
|
|
83
116
|
return 0;
|
|
84
117
|
}
|
|
85
118
|
if (sub === 'run') {
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
// `--file <script>` names a script inside the scripts folder (<org>/<item>/<key>.mjs) instead of the item and the key
|
|
120
|
+
let item = args[1];
|
|
121
|
+
let key = args[2];
|
|
122
|
+
let fileOrg = null;
|
|
123
|
+
const fileFlag = flag('--file');
|
|
124
|
+
if (fileFlag) {
|
|
125
|
+
const rel = relative(scriptsDir(), resolve(fileFlag)).split(/[\\/]/);
|
|
126
|
+
const base = rel[2]?.replace(/\.mjs$/, '');
|
|
127
|
+
if (rel.length !== 3 || rel.includes('..') || !base)
|
|
128
|
+
throw new Error(`--file must be a script inside ${scriptsDir()}, in the form <org>/<item>/<key>.mjs.`);
|
|
129
|
+
fileOrg = rel[0] ?? null;
|
|
130
|
+
item = rel[1];
|
|
131
|
+
key = base;
|
|
132
|
+
}
|
|
88
133
|
if (!item || !key || item.startsWith('--') || key.startsWith('--'))
|
|
89
|
-
throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug>');
|
|
134
|
+
throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug> (or --file <script>)');
|
|
90
135
|
const s = server();
|
|
91
136
|
const o = org();
|
|
137
|
+
// The folder of a script names its organisation: a script of one organisation is never run as another's
|
|
138
|
+
if (fileOrg !== null && fileOrg !== o)
|
|
139
|
+
throw new Error(`--file is a script of the organisation ${fileOrg.slice(0, 40)}, but --org says ${o}. Use the same organisation in both.`);
|
|
92
140
|
const token = loadRunnerToken(ctx.store, s, o);
|
|
93
141
|
if (!token)
|
|
94
142
|
throw new Error(`This machine is not connected to ${o} on ${s}. Run: agent-flow-board check connect --server ${s} --org ${o} --json`);
|
|
@@ -100,6 +148,7 @@ export async function checkMain(ctx) {
|
|
|
100
148
|
dir: resolve(flag('--dir') ?? process.cwd()),
|
|
101
149
|
step: flag('--step'),
|
|
102
150
|
runId: flag('--run'),
|
|
151
|
+
registration: flag('--registration'),
|
|
103
152
|
runnerToken: token,
|
|
104
153
|
personalToken: ctx.personalToken(s, o),
|
|
105
154
|
fetch: ctx.fetch,
|
|
@@ -162,6 +211,69 @@ export async function checkMain(ctx) {
|
|
|
162
211
|
}));
|
|
163
212
|
return 0;
|
|
164
213
|
}
|
|
214
|
+
// The registration, approval, sign in and service commands live in their own modules; each takes the arguments after its word
|
|
215
|
+
if (sub === 'project')
|
|
216
|
+
return await projectCommand(args.slice(1), { log, out, json });
|
|
217
|
+
if (sub === 'approve') {
|
|
218
|
+
// Asking on the board uses this machine's own runner connection; with one connection --server and --org may be left out
|
|
219
|
+
const only = listConnections();
|
|
220
|
+
const board = () => {
|
|
221
|
+
const s = flag('--server') ??
|
|
222
|
+
process.env.AFB_SERVER ??
|
|
223
|
+
(only.length === 1 ? only[0]?.server : undefined);
|
|
224
|
+
const o = flag('--org') ?? (only.length === 1 ? only[0]?.org : undefined);
|
|
225
|
+
if (!s || !o || !ORG_SLUG.test(o))
|
|
226
|
+
throw new Error('Say which board: --server <url> --org <slug> (or connect this machine to one board only).');
|
|
227
|
+
const token = loadRunnerToken(ctx.store, s.replace(/\/$/, ''), o);
|
|
228
|
+
if (!token)
|
|
229
|
+
throw new Error(`This machine is not connected to ${o} on ${s}. Run: agent-flow-board check connect --server ${s} --org ${o} --json`);
|
|
230
|
+
return new RunnerBoard(s, token, ctx.fetch);
|
|
231
|
+
};
|
|
232
|
+
// Ctrl-C or a kill withdraws the request, so no card is left on the board for an agent that is gone
|
|
233
|
+
const ac = new AbortController();
|
|
234
|
+
const stop = () => ac.abort();
|
|
235
|
+
process.once('SIGINT', stop);
|
|
236
|
+
process.once('SIGTERM', stop);
|
|
237
|
+
try {
|
|
238
|
+
return await approveCommand(args.slice(1), { log, out, json, board, signal: ac.signal });
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
process.off('SIGINT', stop);
|
|
242
|
+
process.off('SIGTERM', stop);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (sub === 'roots')
|
|
246
|
+
return await rootsCommand(args.slice(1), { log, out, json });
|
|
247
|
+
if (sub === 'login') {
|
|
248
|
+
const only = listConnections();
|
|
249
|
+
const board = () => {
|
|
250
|
+
const s = flag('--server') ??
|
|
251
|
+
process.env.AFB_SERVER ??
|
|
252
|
+
(only.length === 1 ? only[0]?.server : undefined);
|
|
253
|
+
const o = flag('--org') ?? (only.length === 1 ? only[0]?.org : undefined);
|
|
254
|
+
if (!s || !o || !ORG_SLUG.test(o))
|
|
255
|
+
throw new Error('Say which board: --server <url> --org <slug>.');
|
|
256
|
+
const token = loadRunnerToken(ctx.store, s.replace(/\/$/, ''), o);
|
|
257
|
+
if (!token)
|
|
258
|
+
throw new Error('This machine is not connected to that board. Run: agent-flow-board check connect --server ' +
|
|
259
|
+
s +
|
|
260
|
+
' --org ' +
|
|
261
|
+
o +
|
|
262
|
+
' --json');
|
|
263
|
+
return new RunnerBoard(s, token, ctx.fetch);
|
|
264
|
+
};
|
|
265
|
+
return await loginCommand(args.slice(1), { log, out, json, board });
|
|
266
|
+
}
|
|
267
|
+
if (sub === 'service')
|
|
268
|
+
return await serviceCommand(args.slice(1), { log, out, json });
|
|
269
|
+
if (sub === 'secrets')
|
|
270
|
+
return await secretsCommand(args.slice(1), { log, out, json });
|
|
271
|
+
if (sub === 'import-legacy')
|
|
272
|
+
return await importLegacyCommand(args.slice(1), { log, out, json });
|
|
273
|
+
if (sub === 'clear')
|
|
274
|
+
return await clearCommand(args.slice(1), { log, out, json, store: ctx.store });
|
|
275
|
+
if (sub === 'status' || sub === 'doctor')
|
|
276
|
+
return await healthCommand(args, { log, out, json, store: ctx.store });
|
|
165
277
|
if (sub === 'bind') {
|
|
166
278
|
const o = org();
|
|
167
279
|
const project = need('--project');
|
|
@@ -180,7 +292,8 @@ export async function checkMain(ctx) {
|
|
|
180
292
|
store: ctx.store,
|
|
181
293
|
fetch: ctx.fetch,
|
|
182
294
|
log,
|
|
183
|
-
|
|
295
|
+
// Without --parallel the slots come from the machine (CPUs and free memory); a value that is not a whole number above 0 is refused, never turned into 3
|
|
296
|
+
parallel: parallelFlag(flag('--parallel')),
|
|
184
297
|
once: has('--once'),
|
|
185
298
|
signal: ac.signal
|
|
186
299
|
});
|
|
@@ -192,13 +305,6 @@ export async function checkMain(ctx) {
|
|
|
192
305
|
log('The browser is ready.');
|
|
193
306
|
return 0;
|
|
194
307
|
}
|
|
195
|
-
if (sub === 'status' || sub === 'doctor')
|
|
196
|
-
return await statusOrDoctor(ctx, sub === 'doctor', has('--fix'), json);
|
|
197
|
-
if (sub === 'clear') {
|
|
198
|
-
const r = clearRuns({ all: has('--all') });
|
|
199
|
-
log(`Removed ${r.removed} finished run folder(s).`);
|
|
200
|
-
return 0;
|
|
201
|
-
}
|
|
202
308
|
log(CHECK_USAGE);
|
|
203
309
|
return 2;
|
|
204
310
|
}
|
|
@@ -211,70 +317,3 @@ export async function checkMain(ctx) {
|
|
|
211
317
|
return 2;
|
|
212
318
|
}
|
|
213
319
|
}
|
|
214
|
-
async function statusOrDoctor(ctx, doctor, fix, json) {
|
|
215
|
-
const problems = [];
|
|
216
|
-
const audit = verifyAudit();
|
|
217
|
-
if (!audit.ok)
|
|
218
|
-
problems.push({
|
|
219
|
-
problem: 'The audit log does not verify (it was edited or cut).',
|
|
220
|
-
next: 'Do not edit it. Move checks/audit.log aside to start a new chain.'
|
|
221
|
-
});
|
|
222
|
-
if (!permissionFlag())
|
|
223
|
-
problems.push({
|
|
224
|
-
problem: 'This Node has no permission model, so check scripts cannot be sandboxed.',
|
|
225
|
-
next: 'Install Node 20.18 or later (or any Node 22).'
|
|
226
|
-
});
|
|
227
|
-
if (!playwrightRuntimeDir()) {
|
|
228
|
-
if (doctor && fix)
|
|
229
|
-
await ensureBrowser(ctx.log);
|
|
230
|
-
else
|
|
231
|
-
problems.push({
|
|
232
|
-
problem: 'The browser is not installed.',
|
|
233
|
-
next: 'Run: agent-flow-board check setup'
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
const swept = doctor && fix ? await sweepOrphans(ctx.log) : 0;
|
|
237
|
-
const connections = listConnections().map((c) => ({
|
|
238
|
-
...c,
|
|
239
|
-
connected: Boolean(loadRunnerToken(ctx.store, c.server, c.org))
|
|
240
|
-
}));
|
|
241
|
-
for (const c of connections)
|
|
242
|
-
if (!c.connected)
|
|
243
|
-
problems.push({
|
|
244
|
-
problem: `${c.org} on ${c.server} has no runner token.`,
|
|
245
|
-
next: `Run: agent-flow-board check connect --server ${c.server} --org ${c.org} --json`
|
|
246
|
-
});
|
|
247
|
-
const bindings = listBindings().map((b) => ({
|
|
248
|
-
org: b.org,
|
|
249
|
-
project: b.projectKey,
|
|
250
|
-
folder: b.folder,
|
|
251
|
-
pin: pinStatus(b.org, b.projectKey)
|
|
252
|
-
}));
|
|
253
|
-
for (const b of bindings)
|
|
254
|
-
if (b.pin !== 'ok')
|
|
255
|
-
problems.push({
|
|
256
|
-
problem: `${b.org}/${b.project}: the files that decide what runs changed in ${b.folder}.`,
|
|
257
|
-
next: `In that folder run a check once: agent-flow-board check run <item> <key> --org ${b.org}`
|
|
258
|
-
});
|
|
259
|
-
const state = {
|
|
260
|
-
home: checksHome(),
|
|
261
|
-
connections,
|
|
262
|
-
bindings,
|
|
263
|
-
audit: { ok: audit.ok, lines: audit.lines },
|
|
264
|
-
browser: Boolean(playwrightRuntimeDir()),
|
|
265
|
-
swept,
|
|
266
|
-
problems
|
|
267
|
-
};
|
|
268
|
-
if (json)
|
|
269
|
-
ctx.out(JSON.stringify(state));
|
|
270
|
-
else {
|
|
271
|
-
ctx.log(`State folder: ${state.home}`);
|
|
272
|
-
ctx.log(`Connections: ${connections.map((c) => `${c.org}@${c.server}${c.connected ? '' : ' (no token)'}`).join(', ') || 'none'}`);
|
|
273
|
-
ctx.log(`Bound folders: ${bindings.map((b) => `${b.org}/${b.project} -> ${b.folder} (${b.pin})`).join('; ') || 'none'}`);
|
|
274
|
-
for (const p of problems)
|
|
275
|
-
ctx.log(`Problem: ${p.problem} Next: ${p.next}`);
|
|
276
|
-
if (!problems.length)
|
|
277
|
-
ctx.log('Nothing is wrong.');
|
|
278
|
-
}
|
|
279
|
-
return doctor && problems.length ? 1 : 0;
|
|
280
|
-
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// THE COMMANDS `check project`, `check approve` AND `check roots`. Plain handlers: they take the arguments after the command word and a small context, print through it and return
|
|
2
|
+
// an exit code (0 done, 1 refused with the problems listed, 2 could not run). Nothing here reads a terminal. `check approve` only asks whether it IS on a terminal, because a code is
|
|
3
|
+
// made by the owner and never by an agent.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { isatty } from 'node:tty';
|
|
6
|
+
// -- MODULES -- //
|
|
7
|
+
import { APPROVAL_ACTIONS, Refusal, UsageError, isApprovalAction, mintApproval } from './approve.js';
|
|
8
|
+
import { forgetRegistration, hasState, pinRegistration, pinState, readRegistrations, registerProject } from './registry.js';
|
|
9
|
+
import { addRoot, listRoots, readRoots, removeRoot } from './roots.js';
|
|
10
|
+
import { ASK_LIMIT, askOwner, parseEntries } from './askBoard.js';
|
|
11
|
+
// -- CONSTANTS -- //
|
|
12
|
+
const BOOLEAN_FLAGS = new Set([
|
|
13
|
+
'--recipe',
|
|
14
|
+
'--remove',
|
|
15
|
+
'--adopt-env',
|
|
16
|
+
'--upload-pictures',
|
|
17
|
+
'--request',
|
|
18
|
+
'--json'
|
|
19
|
+
]);
|
|
20
|
+
/** Most codes one `check approve --many` makes (the approvals file keeps 20). */
|
|
21
|
+
const MANY_LIMIT = 15;
|
|
22
|
+
// -- FUNCTIONS -- //
|
|
23
|
+
function parse(args) {
|
|
24
|
+
const pos = [];
|
|
25
|
+
const flags = new Map();
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
const a = args[i] ?? '';
|
|
28
|
+
if (!a.startsWith('--')) {
|
|
29
|
+
pos.push(a);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const eq = a.indexOf('=');
|
|
33
|
+
if (eq > 0) {
|
|
34
|
+
flags.set(a.slice(0, eq), a.slice(eq + 1));
|
|
35
|
+
}
|
|
36
|
+
else if (BOOLEAN_FLAGS.has(a)) {
|
|
37
|
+
flags.set(a, true);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const next = args[i + 1];
|
|
41
|
+
if (next === undefined || next.startsWith('--'))
|
|
42
|
+
throw new UsageError(`${a} needs a value.`);
|
|
43
|
+
flags.set(a, next);
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { pos, flags };
|
|
48
|
+
}
|
|
49
|
+
const text = (p, name) => {
|
|
50
|
+
const v = p.flags.get(name);
|
|
51
|
+
return typeof v === 'string' ? v : undefined;
|
|
52
|
+
};
|
|
53
|
+
function need(p, name) {
|
|
54
|
+
const v = text(p, name);
|
|
55
|
+
if (!v)
|
|
56
|
+
throw new UsageError(`${name} is needed.`);
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
/** Runs a handler body and turns what it throws into an exit code and one plain line. */
|
|
60
|
+
async function guarded(ctx, body) {
|
|
61
|
+
try {
|
|
62
|
+
return await body();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
66
|
+
ctx.log(message);
|
|
67
|
+
return e instanceof Refusal ? 1 : 2;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const show = (ctx, value, lines) => {
|
|
71
|
+
if (ctx.json)
|
|
72
|
+
ctx.out(JSON.stringify(value));
|
|
73
|
+
else
|
|
74
|
+
for (const l of lines)
|
|
75
|
+
ctx.out(l);
|
|
76
|
+
};
|
|
77
|
+
export const PROJECT_USAGE = [
|
|
78
|
+
' project <NAME> --org <slug> --repo <dir> [--app-dir web] [--board-project KEY] [--recipe] [--extra-hosts a,b]',
|
|
79
|
+
' project <NAME> --org <slug> --url https://site [--board-project KEY] [--extra-hosts a,b] --approval <code>',
|
|
80
|
+
' project list [--org <slug>] | project forget <NAME> --org <slug> [--remove] | project pin <NAME> --org <slug>'
|
|
81
|
+
].join('\n');
|
|
82
|
+
/** `check project ...`: register, list, forget or pin a registration. */
|
|
83
|
+
export async function projectCommand(args, ctx) {
|
|
84
|
+
return guarded(ctx, async () => {
|
|
85
|
+
const p = parse(args);
|
|
86
|
+
const json = ctx.json || p.flags.has('--json');
|
|
87
|
+
const c = { ...ctx, json };
|
|
88
|
+
const sub = p.pos[0];
|
|
89
|
+
if (!sub)
|
|
90
|
+
throw new UsageError(`Say what to do.\n${PROJECT_USAGE}`);
|
|
91
|
+
if (sub === 'list') {
|
|
92
|
+
const org = text(p, '--org');
|
|
93
|
+
const { registrations, problems } = readRegistrations(org);
|
|
94
|
+
const rows = registrations.map((r) => ({
|
|
95
|
+
org: r.org,
|
|
96
|
+
name: r.name,
|
|
97
|
+
boardProject: r.boardProject,
|
|
98
|
+
kind: r.url ? 'site' : 'repo',
|
|
99
|
+
repo: r.repo ?? null,
|
|
100
|
+
appDir: r.appDir ?? null,
|
|
101
|
+
url: r.url ?? null,
|
|
102
|
+
pin: pinState(r),
|
|
103
|
+
adoptEnv: r.adoptEnv,
|
|
104
|
+
state: hasState(r.org, r.name)
|
|
105
|
+
}));
|
|
106
|
+
show(c, { registrations: rows, problems }, [
|
|
107
|
+
...rows.map((r) => `${r.org}/${r.name} serves ${r.boardProject} ${r.url ?? `${r.repo}${r.appDir ? ` (${r.appDir})` : ''}`} pin ${r.pin}${r.adoptEnv ? ' adopts .env' : ''}${r.state ? ' holds state' : ''}`),
|
|
108
|
+
...(rows.length ? [] : ['No registrations.']),
|
|
109
|
+
...problems
|
|
110
|
+
]);
|
|
111
|
+
return problems.length ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
if (sub === 'forget') {
|
|
114
|
+
const name = p.pos[1];
|
|
115
|
+
if (!name)
|
|
116
|
+
throw new UsageError('forget needs a registration name.');
|
|
117
|
+
const r = forgetRegistration(need(p, '--org'), name, { remove: p.flags.has('--remove') });
|
|
118
|
+
show(c, r, [
|
|
119
|
+
`${r.removed ? 'Removed' : 'Forgot the saved state of'} ${r.name}${r.hadState ? '' : ' (it held none)'}.`
|
|
120
|
+
]);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (sub === 'pin') {
|
|
124
|
+
const name = p.pos[1];
|
|
125
|
+
if (!name)
|
|
126
|
+
throw new UsageError('pin needs a registration name.');
|
|
127
|
+
const r = pinRegistration(need(p, '--org'), name);
|
|
128
|
+
show(c, { ...r, before: r.before?.slice(0, 12) ?? null, after: r.after.slice(0, 12) }, [
|
|
129
|
+
r.changed
|
|
130
|
+
? `Pinned ${r.name}: ${r.before ? r.before.slice(0, 12) : 'none'} -> ${r.after.slice(0, 12)} (${r.files.join(', ') || 'no files'}).`
|
|
131
|
+
: `${r.name} is already pinned (${r.after.slice(0, 12)}).`
|
|
132
|
+
]);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
if (p.pos.length > 1)
|
|
136
|
+
throw new UsageError(`Unexpected "${p.pos[1]}".\n${PROJECT_USAGE}`);
|
|
137
|
+
const hosts = text(p, '--extra-hosts');
|
|
138
|
+
const input = {
|
|
139
|
+
org: need(p, '--org'),
|
|
140
|
+
name: sub,
|
|
141
|
+
...(text(p, '--board-project') ? { boardProject: text(p, '--board-project') } : {}),
|
|
142
|
+
...(text(p, '--repo') ? { repo: text(p, '--repo') } : {}),
|
|
143
|
+
...(text(p, '--app-dir') ? { appDir: text(p, '--app-dir') } : {}),
|
|
144
|
+
...(text(p, '--url') ? { url: text(p, '--url') } : {}),
|
|
145
|
+
...(hosts
|
|
146
|
+
? {
|
|
147
|
+
extraHosts: hosts
|
|
148
|
+
.split(',')
|
|
149
|
+
.map((h) => h.trim())
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
recipe: p.flags.has('--recipe'),
|
|
154
|
+
adoptEnv: p.flags.has('--adopt-env'),
|
|
155
|
+
uploadSignedInPictures: p.flags.has('--upload-pictures')
|
|
156
|
+
};
|
|
157
|
+
const approvals = {
|
|
158
|
+
...(text(p, '--approval') ? { register: text(p, '--approval') } : {}),
|
|
159
|
+
...(text(p, '--adopt-env-approval')
|
|
160
|
+
? { adoptEnv: text(p, '--adopt-env-approval') }
|
|
161
|
+
: {}),
|
|
162
|
+
...(text(p, '--pictures-approval')
|
|
163
|
+
? { pictures: text(p, '--pictures-approval') }
|
|
164
|
+
: {})
|
|
165
|
+
};
|
|
166
|
+
const reg = registerProject(input, approvals);
|
|
167
|
+
show(c, { org: reg.org, name: reg.name, boardProject: reg.boardProject, database: reg.database }, [
|
|
168
|
+
`Registered ${reg.org}/${reg.name} for ${reg.boardProject}${reg.url ? ` (live site ${new URL(reg.url).hostname})` : ''}. The files that decide what runs are pinned by "check project pin ${reg.name} --org ${reg.org}" or the first run in the folder.`
|
|
169
|
+
]);
|
|
170
|
+
return 0;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* `check approve --action <register|secrets|adopt-env|roots|pictures> --target <name or folder>`: the owner's single-use code. Refused without an interactive terminal.
|
|
175
|
+
* `check approve --many "<action>:<target>;<action>:<target>"` makes several codes in one call (at most 15) under the same rule.
|
|
176
|
+
* `env.isTTY` is for the tests; the command itself passes the real flag of the terminal.
|
|
177
|
+
*/
|
|
178
|
+
export async function approveCommand(args, ctx, env = {}) {
|
|
179
|
+
return guarded(ctx, async () => {
|
|
180
|
+
const p = parse(args);
|
|
181
|
+
const isTTY = env.isTTY ?? (isatty(0) && isatty(1));
|
|
182
|
+
// ASKING THE OWNER ON THE BOARD (no terminal): --request, --request-many or --resume
|
|
183
|
+
if (p.flags.has('--request') || p.flags.has('--request-many') || p.flags.has('--resume'))
|
|
184
|
+
return await requestOnBoard(p, ctx);
|
|
185
|
+
// MANY CODES IN ONE CALL
|
|
186
|
+
// Same rules as one code: a terminal only, one action and one target per code, single use, ten minutes. All entries are checked before any code is made.
|
|
187
|
+
const many = text(p, '--many');
|
|
188
|
+
if (many !== undefined) {
|
|
189
|
+
if (p.flags.has('--action') || p.flags.has('--target'))
|
|
190
|
+
throw new UsageError('Use --many on its own, or --action with --target.');
|
|
191
|
+
const entries = many
|
|
192
|
+
.split(';')
|
|
193
|
+
.map((e) => e.trim())
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.map((e) => {
|
|
196
|
+
const at = e.indexOf(':');
|
|
197
|
+
const kind = at > 0 ? e.slice(0, at).trim() : '';
|
|
198
|
+
const target = at > 0 ? e.slice(at + 1).trim() : '';
|
|
199
|
+
if (!isApprovalAction(kind) || !target)
|
|
200
|
+
throw new UsageError(`"${e.slice(0, 60)}" is not <action>:<target>. Actions: ${APPROVAL_ACTIONS.join(', ')}.`);
|
|
201
|
+
return { action: kind, target };
|
|
202
|
+
});
|
|
203
|
+
if (!entries.length)
|
|
204
|
+
throw new UsageError('--many needs at least one <action>:<target>.');
|
|
205
|
+
if (entries.length > MANY_LIMIT)
|
|
206
|
+
throw new UsageError(`--many makes at most ${MANY_LIMIT} codes at once.`);
|
|
207
|
+
const codes = entries.map((e) => {
|
|
208
|
+
const made = mintApproval(e.action, e.target, { isTTY });
|
|
209
|
+
return { action: e.action, target: e.target, code: made.code, expiresAt: made.expiresAt };
|
|
210
|
+
});
|
|
211
|
+
show(ctx, { codes }, [
|
|
212
|
+
...codes.map((c) => `Approval code for ${c.action} on ${c.target}: ${c.code}`),
|
|
213
|
+
`Each works once and expires at ${codes[0]?.expiresAt ?? ''}. Give each to the command that asked for it, with --approval.`
|
|
214
|
+
]);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
const action = need(p, '--action');
|
|
218
|
+
if (!isApprovalAction(action))
|
|
219
|
+
throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
|
|
220
|
+
const target = need(p, '--target');
|
|
221
|
+
const made = mintApproval(action, target, { isTTY });
|
|
222
|
+
show(ctx, { action, target, code: made.code, expiresAt: made.expiresAt }, [
|
|
223
|
+
`Approval code for ${action} on ${target}: ${made.code}`,
|
|
224
|
+
`It works once and expires at ${made.expiresAt}. Give it to the command that asked for it, with --approval.`
|
|
225
|
+
]);
|
|
226
|
+
return 0;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* `check approve --request --action <x> --target <t> [--why "<sentence>"] [--wait <minutes>]`, `--request-many "<action>:<target>;..."` and `--resume <id,id>`.
|
|
231
|
+
* Files the request on the board, prints the page link, waits for the owner's click (default 10 minutes) and prints the codes. Exit 0 approved, 1 refused or expired,
|
|
232
|
+
* 2 could not ask, 3 still waiting (the ids to resume are printed).
|
|
233
|
+
*/
|
|
234
|
+
async function requestOnBoard(p, ctx) {
|
|
235
|
+
if (!ctx.board)
|
|
236
|
+
throw new UsageError('Asking on the board needs the check command with --server and --org.');
|
|
237
|
+
const many = text(p, '--request-many');
|
|
238
|
+
const resume = text(p, '--resume');
|
|
239
|
+
const minutes = text(p, '--wait') === undefined ? 10 : Number(text(p, '--wait'));
|
|
240
|
+
if (!Number.isInteger(minutes) || minutes < 0 || minutes > 60)
|
|
241
|
+
throw new UsageError('--wait takes a whole number of minutes from 0 to 60.');
|
|
242
|
+
let entries = [];
|
|
243
|
+
if (resume === undefined) {
|
|
244
|
+
if (many !== undefined) {
|
|
245
|
+
if (p.flags.has('--action') || p.flags.has('--target'))
|
|
246
|
+
throw new UsageError('Use --request-many on its own, or --request with --action and --target.');
|
|
247
|
+
entries = parseEntries(many);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const action = need(p, '--action');
|
|
251
|
+
if (!isApprovalAction(action))
|
|
252
|
+
throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
|
|
253
|
+
entries = [{ action, target: need(p, '--target') }];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const ids = resume
|
|
257
|
+
?.split(',')
|
|
258
|
+
.map((s) => s.trim())
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
if (ids &&
|
|
261
|
+
(!ids.length || ids.length > ASK_LIMIT || ids.some((i) => !/^[0-9a-f-]{36}$/i.test(i))))
|
|
262
|
+
throw new UsageError('--resume takes the ids that were printed, separated by commas.');
|
|
263
|
+
const done = await askOwner(ctx.board(), {
|
|
264
|
+
entries,
|
|
265
|
+
why: text(p, '--why'),
|
|
266
|
+
project: text(p, '--project'),
|
|
267
|
+
waitMinutes: minutes,
|
|
268
|
+
resume: ids,
|
|
269
|
+
say: (line) => ctx.log(line),
|
|
270
|
+
signal: ctx.signal
|
|
271
|
+
});
|
|
272
|
+
if (done.state === 'waiting') {
|
|
273
|
+
const list = done.ids.join(',');
|
|
274
|
+
show(ctx, { state: 'waiting', ids: done.ids, link: done.link, resume: list }, [
|
|
275
|
+
`Still waiting for the owner. Pick it up again with: agent-flow-board check approve --resume ${list} --wait 10`
|
|
276
|
+
]);
|
|
277
|
+
return 3;
|
|
278
|
+
}
|
|
279
|
+
show(ctx, { state: 'approved', codes: done.codes }, [
|
|
280
|
+
...done.codes.map((c) => `Approval code for ${c.action} on ${c.target}: ${c.code}`),
|
|
281
|
+
`Each works once and expires at ${done.codes[0]?.expiresAt ?? ''}. Give each to the command that asked for it, with --approval.`
|
|
282
|
+
]);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
/** `check roots add <dir> --approval <code> | list | remove <dir> --approval <code>`. */
|
|
286
|
+
export async function rootsCommand(args, ctx) {
|
|
287
|
+
return guarded(ctx, () => {
|
|
288
|
+
const p = parse(args);
|
|
289
|
+
const sub = p.pos[0] ?? 'list';
|
|
290
|
+
if (sub === 'list') {
|
|
291
|
+
const { roots, problem } = readRoots();
|
|
292
|
+
show(ctx, { roots, problem }, [
|
|
293
|
+
...(roots.length
|
|
294
|
+
? roots
|
|
295
|
+
: ['No roots. The owner adds one: check roots add <folder> --approval <code>.']),
|
|
296
|
+
...(problem ? [problem] : [])
|
|
297
|
+
]);
|
|
298
|
+
return problem ? 1 : 0;
|
|
299
|
+
}
|
|
300
|
+
const dir = p.pos[1];
|
|
301
|
+
if (!dir)
|
|
302
|
+
throw new UsageError(`${sub} needs a folder.`);
|
|
303
|
+
if (sub === 'add') {
|
|
304
|
+
const real = addRoot(dir, text(p, '--approval'));
|
|
305
|
+
show(ctx, { root: real, roots: listRoots() }, [`Root added: ${real}`]);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
if (sub === 'remove') {
|
|
309
|
+
const real = removeRoot(dir, text(p, '--approval'));
|
|
310
|
+
show(ctx, { root: real, roots: listRoots() }, [`Root removed: ${real}`]);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
throw new UsageError('Use: roots add <dir> --approval <code> | roots list | roots remove <dir> --approval <code>');
|
|
314
|
+
});
|
|
315
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// THE BOARDS THIS MACHINE IS CONNECTED TO FOR CHECKS: SERVER AND ORGANISATION ONLY, NEVER A TOKEN (THE TOKEN IS IN THE TOKEN STORE)
|
|
2
2
|
// `check watch` re-reads this list on every loop, so a `check connect` for a second organisation is picked up without a restart.
|
|
3
3
|
// -- DEP-MODULES -- //
|
|
4
|
-
import { existsSync, readFileSync,
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
// -- MODULES -- //
|
|
7
7
|
import { checksHome, ensureDir, ORG_SLUG } from './paths.js';
|
|
8
|
+
import { renameRetry } from './fsretry.js';
|
|
8
9
|
// -- FUNCTIONS -- //
|
|
9
10
|
const file = () => join(checksHome(), 'connections.json');
|
|
10
11
|
export function listConnections() {
|
|
@@ -30,12 +31,12 @@ export function recordConnection(c) {
|
|
|
30
31
|
ensureDir(checksHome());
|
|
31
32
|
const tmp = `${file()}.${process.pid}.tmp`;
|
|
32
33
|
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
33
|
-
|
|
34
|
+
renameRetry(tmp, file());
|
|
34
35
|
}
|
|
35
36
|
export function forgetConnection(server, org) {
|
|
36
37
|
const next = listConnections().filter((x) => !(x.server === server && x.org === org));
|
|
37
38
|
ensureDir(checksHome());
|
|
38
39
|
const tmp = `${file()}.${process.pid}.tmp`;
|
|
39
40
|
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
40
|
-
|
|
41
|
+
renameRetry(tmp, file());
|
|
41
42
|
}
|