@agent-flow-board/bridge 0.2.0 → 0.3.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 (51) hide show
  1. package/README.md +16 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +3 -2
  5. package/dist/checks/bind.js +3 -2
  6. package/dist/checks/broker.js +439 -0
  7. package/dist/checks/browser.js +137 -32
  8. package/dist/checks/browserpool.js +221 -0
  9. package/dist/checks/cache.js +710 -0
  10. package/dist/checks/command.js +75 -88
  11. package/dist/checks/commands.js +219 -0
  12. package/dist/checks/connections.js +4 -3
  13. package/dist/checks/copy.js +320 -0
  14. package/dist/checks/dbpool.js +710 -0
  15. package/dist/checks/doctor.js +649 -0
  16. package/dist/checks/envPolicy.js +50 -1
  17. package/dist/checks/envrules.js +322 -0
  18. package/dist/checks/evidence.js +114 -0
  19. package/dist/checks/fsretry.js +29 -0
  20. package/dist/checks/importlegacy.js +300 -0
  21. package/dist/checks/install.js +150 -0
  22. package/dist/checks/live.js +76 -0
  23. package/dist/checks/lock.js +28 -4
  24. package/dist/checks/login.js +203 -0
  25. package/dist/checks/mailguard-hook.cjs +216 -0
  26. package/dist/checks/mailguard.js +98 -0
  27. package/dist/checks/paths.js +24 -0
  28. package/dist/checks/preview.js +418 -191
  29. package/dist/checks/profiles.js +110 -0
  30. package/dist/checks/provision.js +1 -1
  31. package/dist/checks/recipe.js +19 -5
  32. package/dist/checks/registry.js +548 -0
  33. package/dist/checks/repoenv.js +162 -0
  34. package/dist/checks/roots.js +144 -0
  35. package/dist/checks/routing.js +68 -0
  36. package/dist/checks/run.js +243 -53
  37. package/dist/checks/sandbox.js +32 -10
  38. package/dist/checks/scheduler.js +383 -0
  39. package/dist/checks/scope.js +119 -30
  40. package/dist/checks/sealedNames.js +39 -0
  41. package/dist/checks/secrets.js +452 -0
  42. package/dist/checks/secretscmd.js +80 -0
  43. package/dist/checks/service.js +589 -0
  44. package/dist/checks/signin.js +7 -1
  45. package/dist/checks/singleflight.js +307 -0
  46. package/dist/checks/store.js +92 -0
  47. package/dist/checks/surface.js +124 -0
  48. package/dist/checks/warm.js +342 -0
  49. package/dist/checks/watch.js +190 -63
  50. package/dist/checks/wire.js +87 -0
  51. package/package.json +1 -1
@@ -4,19 +4,22 @@
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
- import { forgetConnection, listConnections, recordConnection } from './connections.js';
14
- import { ORG_SLUG, checksHome, scriptPath } from './paths.js';
12
+ import { forgetConnection, recordConnection } from './connections.js';
13
+ import { ORG_SLUG, scriptPath, scriptsDir } from './paths.js';
15
14
  import { pinFiles, resolveProjectConfig } from './recipe.js';
16
15
  import { runCheck } from './run.js';
17
- import { clearRuns, sweepOrphans } from './scope.js';
18
- import { permissionFlag } from './sandbox.js';
19
16
  import { watchChecks } from './watch.js';
17
+ import { approveCommand, projectCommand, rootsCommand } from './commands.js';
18
+ import { clearCommand, healthCommand } from './doctor.js';
19
+ import { importLegacyCommand } from './importlegacy.js';
20
+ import { loginCommand } from './login.js';
21
+ import { secretsCommand } from './secretscmd.js';
22
+ import { serviceCommand } from './service.js';
20
23
  // -- CONSTANTS -- //
21
24
  export const CHECK_USAGE = [
22
25
  'Usage: agent-flow-board check <command>',
@@ -25,14 +28,36 @@ export const CHECK_USAGE = [
25
28
  ' pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>] read the board copy of a script; accept it by hash',
26
29
  ' bind --org <slug> --project <KEY> [--dir <repo>] remember the folder of a board project for "check watch"',
27
30
  ' 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>'
31
+ ' run ... also takes [--registration NAME] [--file <script inside the scripts folder>] [--headed]',
32
+ ' 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',
33
+ ' approve --action <roots|register|secrets|adopt-env|pictures> --target <name> (the owner, on a terminal) | roots add|list|remove <dir> [--approval <code>]',
34
+ ' 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>]',
35
+ ' login <NAME> --org <slug> --seeded | service install|uninstall|status [--no-start] | import-legacy --from <afb-checks folder> --org <slug> [--dry-run]',
36
+ ' status [--json] | doctor [--fix] [--json] | clear [runs|profiles|auth|dbs|previews|all] [--all] [--yes] | setup | disconnect --server <url> --org <slug>'
29
37
  ].join('\n');
30
38
  // -- FUNCTIONS -- //
39
+ /** The value of --parallel: undefined when the flag is absent (the machine decides), else a whole number from 1 up. */
40
+ export function parallelFlag(raw) {
41
+ if (raw === undefined)
42
+ return undefined;
43
+ const n = Number(raw);
44
+ if (!Number.isInteger(n) || n < 1)
45
+ throw new Error(`--parallel takes a whole number of 1 or more, not "${raw.slice(0, 20)}".`);
46
+ return n;
47
+ }
31
48
  /** 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
49
  export async function checkMain(ctx) {
33
50
  const { args, log, out } = ctx;
34
51
  const sub = args[0];
35
- const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
52
+ // 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
53
+ const flag = (name) => {
54
+ if (!args.includes(name))
55
+ return undefined;
56
+ const v = args[args.indexOf(name) + 1];
57
+ if (v === undefined || v.startsWith('--'))
58
+ throw new Error(`Say ${name} <value>.`);
59
+ return v;
60
+ };
36
61
  const has = (name) => args.includes(name);
37
62
  const json = has('--json');
38
63
  const need = (name) => {
@@ -83,12 +108,27 @@ export async function checkMain(ctx) {
83
108
  return 0;
84
109
  }
85
110
  if (sub === 'run') {
86
- const item = args[1];
87
- const key = args[2];
111
+ // `--file <script>` names a script inside the scripts folder (<org>/<item>/<key>.mjs) instead of the item and the key
112
+ let item = args[1];
113
+ let key = args[2];
114
+ let fileOrg = null;
115
+ const fileFlag = flag('--file');
116
+ if (fileFlag) {
117
+ const rel = relative(scriptsDir(), resolve(fileFlag)).split(/[\\/]/);
118
+ const base = rel[2]?.replace(/\.mjs$/, '');
119
+ if (rel.length !== 3 || rel.includes('..') || !base)
120
+ throw new Error(`--file must be a script inside ${scriptsDir()}, in the form <org>/<item>/<key>.mjs.`);
121
+ fileOrg = rel[0] ?? null;
122
+ item = rel[1];
123
+ key = base;
124
+ }
88
125
  if (!item || !key || item.startsWith('--') || key.startsWith('--'))
89
- throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug>');
126
+ throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug> (or --file <script>)');
90
127
  const s = server();
91
128
  const o = org();
129
+ // The folder of a script names its organisation: a script of one organisation is never run as another's
130
+ if (fileOrg !== null && fileOrg !== o)
131
+ 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
132
  const token = loadRunnerToken(ctx.store, s, o);
93
133
  if (!token)
94
134
  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 +140,7 @@ export async function checkMain(ctx) {
100
140
  dir: resolve(flag('--dir') ?? process.cwd()),
101
141
  step: flag('--step'),
102
142
  runId: flag('--run'),
143
+ registration: flag('--registration'),
103
144
  runnerToken: token,
104
145
  personalToken: ctx.personalToken(s, o),
105
146
  fetch: ctx.fetch,
@@ -162,6 +203,25 @@ export async function checkMain(ctx) {
162
203
  }));
163
204
  return 0;
164
205
  }
206
+ // The registration, approval, sign in and service commands live in their own modules; each takes the arguments after its word
207
+ if (sub === 'project')
208
+ return await projectCommand(args.slice(1), { log, out, json });
209
+ if (sub === 'approve')
210
+ return await approveCommand(args.slice(1), { log, out, json });
211
+ if (sub === 'roots')
212
+ return await rootsCommand(args.slice(1), { log, out, json });
213
+ if (sub === 'login')
214
+ return await loginCommand(args.slice(1), { log, out, json });
215
+ if (sub === 'service')
216
+ return await serviceCommand(args.slice(1), { log, out, json });
217
+ if (sub === 'secrets')
218
+ return await secretsCommand(args.slice(1), { log, out, json });
219
+ if (sub === 'import-legacy')
220
+ return await importLegacyCommand(args.slice(1), { log, out, json });
221
+ if (sub === 'clear')
222
+ return await clearCommand(args.slice(1), { log, out, json, store: ctx.store });
223
+ if (sub === 'status' || sub === 'doctor')
224
+ return await healthCommand(args, { log, out, json, store: ctx.store });
165
225
  if (sub === 'bind') {
166
226
  const o = org();
167
227
  const project = need('--project');
@@ -180,7 +240,8 @@ export async function checkMain(ctx) {
180
240
  store: ctx.store,
181
241
  fetch: ctx.fetch,
182
242
  log,
183
- parallel: Number(flag('--parallel') ?? 3) || 3,
243
+ // 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
244
+ parallel: parallelFlag(flag('--parallel')),
184
245
  once: has('--once'),
185
246
  signal: ac.signal
186
247
  });
@@ -192,13 +253,6 @@ export async function checkMain(ctx) {
192
253
  log('The browser is ready.');
193
254
  return 0;
194
255
  }
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
256
  log(CHECK_USAGE);
203
257
  return 2;
204
258
  }
@@ -211,70 +265,3 @@ export async function checkMain(ctx) {
211
265
  return 2;
212
266
  }
213
267
  }
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,219 @@
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
+ // -- CONSTANTS -- //
11
+ const BOOLEAN_FLAGS = new Set([
12
+ '--recipe',
13
+ '--remove',
14
+ '--adopt-env',
15
+ '--upload-pictures',
16
+ '--json'
17
+ ]);
18
+ // -- FUNCTIONS -- //
19
+ function parse(args) {
20
+ const pos = [];
21
+ const flags = new Map();
22
+ for (let i = 0; i < args.length; i++) {
23
+ const a = args[i] ?? '';
24
+ if (!a.startsWith('--')) {
25
+ pos.push(a);
26
+ continue;
27
+ }
28
+ const eq = a.indexOf('=');
29
+ if (eq > 0) {
30
+ flags.set(a.slice(0, eq), a.slice(eq + 1));
31
+ }
32
+ else if (BOOLEAN_FLAGS.has(a)) {
33
+ flags.set(a, true);
34
+ }
35
+ else {
36
+ const next = args[i + 1];
37
+ if (next === undefined || next.startsWith('--'))
38
+ throw new UsageError(`${a} needs a value.`);
39
+ flags.set(a, next);
40
+ i++;
41
+ }
42
+ }
43
+ return { pos, flags };
44
+ }
45
+ const text = (p, name) => {
46
+ const v = p.flags.get(name);
47
+ return typeof v === 'string' ? v : undefined;
48
+ };
49
+ function need(p, name) {
50
+ const v = text(p, name);
51
+ if (!v)
52
+ throw new UsageError(`${name} is needed.`);
53
+ return v;
54
+ }
55
+ /** Runs a handler body and turns what it throws into an exit code and one plain line. */
56
+ async function guarded(ctx, body) {
57
+ try {
58
+ return await body();
59
+ }
60
+ catch (e) {
61
+ const message = e instanceof Error ? e.message : String(e);
62
+ ctx.log(message);
63
+ return e instanceof Refusal ? 1 : 2;
64
+ }
65
+ }
66
+ const show = (ctx, value, lines) => {
67
+ if (ctx.json)
68
+ ctx.out(JSON.stringify(value));
69
+ else
70
+ for (const l of lines)
71
+ ctx.out(l);
72
+ };
73
+ export const PROJECT_USAGE = [
74
+ ' project <NAME> --org <slug> --repo <dir> [--app-dir web] [--board-project KEY] [--recipe] [--extra-hosts a,b]',
75
+ ' project <NAME> --org <slug> --url https://site [--board-project KEY] [--extra-hosts a,b] --approval <code>',
76
+ ' project list [--org <slug>] | project forget <NAME> --org <slug> [--remove] | project pin <NAME> --org <slug>'
77
+ ].join('\n');
78
+ /** `check project ...`: register, list, forget or pin a registration. */
79
+ export async function projectCommand(args, ctx) {
80
+ return guarded(ctx, async () => {
81
+ const p = parse(args);
82
+ const json = ctx.json || p.flags.has('--json');
83
+ const c = { ...ctx, json };
84
+ const sub = p.pos[0];
85
+ if (!sub)
86
+ throw new UsageError(`Say what to do.\n${PROJECT_USAGE}`);
87
+ if (sub === 'list') {
88
+ const org = text(p, '--org');
89
+ const { registrations, problems } = readRegistrations(org);
90
+ const rows = registrations.map((r) => ({
91
+ org: r.org,
92
+ name: r.name,
93
+ boardProject: r.boardProject,
94
+ kind: r.url ? 'site' : 'repo',
95
+ repo: r.repo ?? null,
96
+ appDir: r.appDir ?? null,
97
+ url: r.url ?? null,
98
+ pin: pinState(r),
99
+ adoptEnv: r.adoptEnv,
100
+ state: hasState(r.org, r.name)
101
+ }));
102
+ show(c, { registrations: rows, problems }, [
103
+ ...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' : ''}`),
104
+ ...(rows.length ? [] : ['No registrations.']),
105
+ ...problems
106
+ ]);
107
+ return problems.length ? 1 : 0;
108
+ }
109
+ if (sub === 'forget') {
110
+ const name = p.pos[1];
111
+ if (!name)
112
+ throw new UsageError('forget needs a registration name.');
113
+ const r = forgetRegistration(need(p, '--org'), name, { remove: p.flags.has('--remove') });
114
+ show(c, r, [
115
+ `${r.removed ? 'Removed' : 'Forgot the saved state of'} ${r.name}${r.hadState ? '' : ' (it held none)'}.`
116
+ ]);
117
+ return 0;
118
+ }
119
+ if (sub === 'pin') {
120
+ const name = p.pos[1];
121
+ if (!name)
122
+ throw new UsageError('pin needs a registration name.');
123
+ const r = pinRegistration(need(p, '--org'), name);
124
+ show(c, { ...r, before: r.before?.slice(0, 12) ?? null, after: r.after.slice(0, 12) }, [
125
+ r.changed
126
+ ? `Pinned ${r.name}: ${r.before ? r.before.slice(0, 12) : 'none'} -> ${r.after.slice(0, 12)} (${r.files.join(', ') || 'no files'}).`
127
+ : `${r.name} is already pinned (${r.after.slice(0, 12)}).`
128
+ ]);
129
+ return 0;
130
+ }
131
+ if (p.pos.length > 1)
132
+ throw new UsageError(`Unexpected "${p.pos[1]}".\n${PROJECT_USAGE}`);
133
+ const hosts = text(p, '--extra-hosts');
134
+ const input = {
135
+ org: need(p, '--org'),
136
+ name: sub,
137
+ ...(text(p, '--board-project') ? { boardProject: text(p, '--board-project') } : {}),
138
+ ...(text(p, '--repo') ? { repo: text(p, '--repo') } : {}),
139
+ ...(text(p, '--app-dir') ? { appDir: text(p, '--app-dir') } : {}),
140
+ ...(text(p, '--url') ? { url: text(p, '--url') } : {}),
141
+ ...(hosts
142
+ ? {
143
+ extraHosts: hosts
144
+ .split(',')
145
+ .map((h) => h.trim())
146
+ .filter(Boolean)
147
+ }
148
+ : {}),
149
+ recipe: p.flags.has('--recipe'),
150
+ adoptEnv: p.flags.has('--adopt-env'),
151
+ uploadSignedInPictures: p.flags.has('--upload-pictures')
152
+ };
153
+ const approvals = {
154
+ ...(text(p, '--approval') ? { register: text(p, '--approval') } : {}),
155
+ ...(text(p, '--adopt-env-approval')
156
+ ? { adoptEnv: text(p, '--adopt-env-approval') }
157
+ : {}),
158
+ ...(text(p, '--pictures-approval')
159
+ ? { pictures: text(p, '--pictures-approval') }
160
+ : {})
161
+ };
162
+ const reg = registerProject(input, approvals);
163
+ show(c, { org: reg.org, name: reg.name, boardProject: reg.boardProject, database: reg.database }, [
164
+ `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.`
165
+ ]);
166
+ return 0;
167
+ });
168
+ }
169
+ /**
170
+ * `check approve --action <register|secrets|adopt-env|roots|pictures> --target <name or folder>`: the owner's single-use code. Refused without an interactive terminal.
171
+ * `env.isTTY` is for the tests; the command itself passes the real flag of the terminal.
172
+ */
173
+ export async function approveCommand(args, ctx, env = {}) {
174
+ return guarded(ctx, () => {
175
+ const p = parse(args);
176
+ const action = need(p, '--action');
177
+ if (!isApprovalAction(action))
178
+ throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
179
+ const target = need(p, '--target');
180
+ const isTTY = env.isTTY ?? (isatty(0) && isatty(1));
181
+ const made = mintApproval(action, target, { isTTY });
182
+ show(ctx, { action, target, code: made.code, expiresAt: made.expiresAt }, [
183
+ `Approval code for ${action} on ${target}: ${made.code}`,
184
+ `It works once and expires at ${made.expiresAt}. Give it to the command that asked for it, with --approval.`
185
+ ]);
186
+ return 0;
187
+ });
188
+ }
189
+ /** `check roots add <dir> --approval <code> | list | remove <dir> --approval <code>`. */
190
+ export async function rootsCommand(args, ctx) {
191
+ return guarded(ctx, () => {
192
+ const p = parse(args);
193
+ const sub = p.pos[0] ?? 'list';
194
+ if (sub === 'list') {
195
+ const { roots, problem } = readRoots();
196
+ show(ctx, { roots, problem }, [
197
+ ...(roots.length
198
+ ? roots
199
+ : ['No roots. The owner adds one: check roots add <folder> --approval <code>.']),
200
+ ...(problem ? [problem] : [])
201
+ ]);
202
+ return problem ? 1 : 0;
203
+ }
204
+ const dir = p.pos[1];
205
+ if (!dir)
206
+ throw new UsageError(`${sub} needs a folder.`);
207
+ if (sub === 'add') {
208
+ const real = addRoot(dir, text(p, '--approval'));
209
+ show(ctx, { root: real, roots: listRoots() }, [`Root added: ${real}`]);
210
+ return 0;
211
+ }
212
+ if (sub === 'remove') {
213
+ const real = removeRoot(dir, text(p, '--approval'));
214
+ show(ctx, { root: real, roots: listRoots() }, [`Root removed: ${real}`]);
215
+ return 0;
216
+ }
217
+ throw new UsageError('Use: roots add <dir> --approval <code> | roots list | roots remove <dir> --approval <code>');
218
+ });
219
+ }
@@ -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
  }