@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.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -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 { verifyAudit } from './audit.js';
10
- import { bindFolder, listBindings, pinStatus } from './bind.js';
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 { ORG_SLUG, checksHome, scriptPath } from './paths.js';
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
- ' status [--json] | doctor [--fix] [--json] | clear [--all] | setup | disconnect --server <url> --org <slug>'
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
- const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
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
- const item = args[1];
87
- const key = args[2];
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
- parallel: Number(flag('--parallel') ?? 3) || 3,
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, renameSync, writeFileSync } from 'node:fs';
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
- renameSync(tmp, file());
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
- renameSync(tmp, file());
41
+ renameRetry(tmp, file());
41
42
  }