@agent-flow-board/bridge 0.1.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 (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,211 @@
1
+ // RUNS ONE CHECK FILE IN ITS OWN PROCESS, started by sandbox.ts with Node's permission model on (dist/checks/child.js).
2
+ // It reads one job from stdin (the saved sign-in is handed over there, never written to disk here), drives the page, writes its
3
+ // result and its pictures into the run folder, and prints one line per event. It never sees the board or any token.
4
+ // Under the permission model there are no child processes, no worker threads and no file access outside the folders sandbox.ts names,
5
+ // and the only environment variable it reads is AFB_PLAYWRIGHT_DIR. It imports nothing that touches the bridge's token store.
6
+ // -- DEP-MODULES -- //
7
+ import { mkdirSync, writeFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { join } from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ // -- MODULES -- //
12
+ import { guardContext, pageForCheck, unwrap } from './guard.js';
13
+ import { redact } from './redact.js';
14
+ // -- CONSTANTS -- //
15
+ const CHECK_LIMIT_MS = 120_000;
16
+ const MAX_SHOT_HEIGHT = 2400;
17
+ // -- FUNCTIONS -- //
18
+ const readStdin = () => new Promise((resolve) => {
19
+ let s = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', (d) => (s += d));
22
+ process.stdin.on('end', () => resolve(s));
23
+ });
24
+ const messageOf = (e) => (e instanceof Error ? e.message : String(e));
25
+ async function main() {
26
+ const job = JSON.parse(await readStdin());
27
+ const needles = job.needles ?? [];
28
+ const extraHosts = job.extraHosts ?? [];
29
+ const origin = new URL(job.baseUrl).origin;
30
+ // PLAYWRIGHT: the parent names the node_modules folder that holds it; it is the only place outside the run folder this process can read
31
+ const pwDir = process.env.AFB_PLAYWRIGHT_DIR;
32
+ if (!pwDir)
33
+ throw new Error('The parent did not say where Playwright is.');
34
+ const req = createRequire(import.meta.url);
35
+ const { chromium } = req(join(pwDir, 'playwright-core'));
36
+ // expect() comes with @playwright/test. Without it a check that calls expect gets one plain sentence
37
+ let realExpect;
38
+ try {
39
+ realExpect = req(join(pwDir, '@playwright', 'test')).expect;
40
+ }
41
+ catch {
42
+ realExpect = () => {
43
+ throw new Error('expect() needs @playwright/test, which is not installed next to Playwright. Run "agent-flow-board check setup".');
44
+ };
45
+ }
46
+ // expect() needs the real locator, so it is given one here, and the check only ever sees the matchers
47
+ const checkExpect = Object.assign((value, message) => realExpect(unwrap(value), message), {
48
+ soft: (value, message) => realExpect.soft?.(unwrap(value), message),
49
+ poll: (fn, options) => realExpect.poll?.(fn, options)
50
+ });
51
+ const out = (event) => process.stdout.write(`EVENT ${JSON.stringify(event)}\n`);
52
+ const say = (m) => out({ type: 'log', text: redact(String(m), needles).slice(0, 600) });
53
+ const shotsDir = join(job.runDir, 'shots');
54
+ mkdirSync(shotsDir, { recursive: true });
55
+ const steps = [];
56
+ const lines = [];
57
+ let n = 0;
58
+ let page;
59
+ let expiredSeen = false;
60
+ let counts = { writes: 0, outside: 0 };
61
+ async function takeShot(caption) {
62
+ if (!page)
63
+ return null;
64
+ const file = `${String(++n).padStart(2, '0')}.jpg`;
65
+ try {
66
+ const height = await page.evaluate(() => document.documentElement.scrollHeight);
67
+ const bytes = await page.screenshot({
68
+ type: 'jpeg',
69
+ quality: 55,
70
+ fullPage: true,
71
+ ...(height > MAX_SHOT_HEIGHT
72
+ ? { clip: { x: 0, y: 0, width: job.viewport.width, height: MAX_SHOT_HEIGHT } }
73
+ : {})
74
+ });
75
+ writeFileSync(join(shotsDir, file), bytes);
76
+ return { file, caption };
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ let result = 'pass';
83
+ let reason;
84
+ const started = Date.now();
85
+ // The browser was started by the tool; this process only connects to it, so it never has to start a program itself
86
+ const browser = await chromium.connect(job.ws);
87
+ try {
88
+ const ctx = await browser.newContext({
89
+ baseURL: job.baseUrl,
90
+ viewport: job.viewport,
91
+ storageState: job.storageState ?? undefined,
92
+ serviceWorkers: 'block'
93
+ });
94
+ // ChildJob has no allowWrites: a check run in the sandbox never writes
95
+ counts = await guardContext(ctx, { origin, allowWrites: false, extraHosts, onBlocked: say });
96
+ const real = await ctx.newPage();
97
+ page = real;
98
+ let violation = null;
99
+ // A page that ends up anywhere but the app under test (file:, chrome:, view-source:) fails the run
100
+ real.on('framenavigated', (f) => {
101
+ try {
102
+ const u = new URL(f.url());
103
+ if (u.protocol !== 'about:' &&
104
+ (!['http:', 'https:'].includes(u.protocol) ||
105
+ (u.origin !== origin && !extraHosts.includes(u.hostname))))
106
+ violation = `The page went to ${u.protocol}//${u.host}, which is not the app under test.`;
107
+ }
108
+ catch {
109
+ /* about:blank and the like */
110
+ }
111
+ });
112
+ // A signed-in run that is sent to a sign-in page has an expired sign-in
113
+ real.on('framenavigated', (f) => {
114
+ try {
115
+ if (f === real.mainFrame() &&
116
+ job.storageState &&
117
+ /\/(sign-?in|log-?in|login|auth)(\/|$|\?)/i.test(new URL(f.url()).pathname))
118
+ expiredSeen = true;
119
+ }
120
+ catch {
121
+ /* about:blank and the like */
122
+ }
123
+ });
124
+ const step = async (name, fn) => {
125
+ const t0 = Date.now();
126
+ try {
127
+ await fn();
128
+ const shot = await takeShot(name);
129
+ steps.push({ name, status: 'pass', ms: Date.now() - t0, shot: shot?.file ?? null });
130
+ out({ type: 'step', name, status: 'pass', ms: Date.now() - t0 });
131
+ }
132
+ catch (e) {
133
+ const shot = await takeShot(`${name} (failed)`);
134
+ const error = redact(messageOf(e).split('\n').slice(0, 4).join(' '), needles).slice(0, 500);
135
+ steps.push({ name, status: 'fail', ms: Date.now() - t0, shot: shot?.file ?? null, error });
136
+ out({ type: 'step', name, status: 'fail', ms: Date.now() - t0, error });
137
+ throw e;
138
+ }
139
+ };
140
+ const shot = async (caption = 'Shot') => {
141
+ const s = await takeShot(caption);
142
+ if (s)
143
+ steps.push({ name: caption, status: 'shot', ms: 0, shot: s.file });
144
+ };
145
+ const log = (m) => {
146
+ const t = redact(String(m), needles);
147
+ lines.push(t);
148
+ say(t);
149
+ };
150
+ const fn = (await import(pathToFileURL(job.checkFile).href)).default;
151
+ if (typeof fn !== 'function')
152
+ throw new Error('A check file must export default an async function({ page, step, shot, expect, log, baseUrl }).');
153
+ let timer;
154
+ try {
155
+ await Promise.race([
156
+ fn({
157
+ page: pageForCheck(real, { origin, extraHosts }),
158
+ step,
159
+ shot,
160
+ expect: checkExpect,
161
+ log,
162
+ baseUrl: job.baseUrl
163
+ }),
164
+ new Promise((_, rej) => (timer = setTimeout(() => rej(new Error('The check took longer than 2 minutes.')), CHECK_LIMIT_MS)))
165
+ ]);
166
+ }
167
+ finally {
168
+ clearTimeout(timer);
169
+ }
170
+ if (violation)
171
+ throw new Error(violation);
172
+ if (!steps.length) {
173
+ const s = await takeShot('End of the check');
174
+ if (s)
175
+ steps.push({ name: 'End of the check', status: 'shot', ms: 0, shot: s.file });
176
+ }
177
+ }
178
+ catch (e) {
179
+ result = 'fail';
180
+ reason = redact(messageOf(e).split('\n').slice(0, 4).join(' '), needles).slice(0, 700);
181
+ if (!steps.some((s) => s.status === 'fail') && page) {
182
+ const s = await takeShot('Where it stopped');
183
+ if (s)
184
+ steps.push({ name: 'Where it stopped', status: 'shot', ms: 0, shot: s.file });
185
+ }
186
+ }
187
+ finally {
188
+ await browser.close().catch(() => { });
189
+ }
190
+ if (result === 'fail' && expiredSeen)
191
+ reason =
192
+ `The saved sign-in no longer works: the app sent the browser to its sign-in page. Sign in again and rerun. (${reason ?? ''})`.trim();
193
+ const final = {
194
+ schema: 1,
195
+ result,
196
+ reason: reason ?? null,
197
+ durationMs: Date.now() - started,
198
+ steps,
199
+ log: lines.join('\n'),
200
+ blockedWrites: counts.writes,
201
+ blockedOutside: counts.outside
202
+ };
203
+ writeFileSync(join(job.runDir, 'result.json'), JSON.stringify(final, null, 1));
204
+ out({ type: 'done', result });
205
+ }
206
+ main().then(
207
+ // Nothing else is waiting, so end now instead of waiting for stray timers
208
+ () => process.exit(0), (e) => {
209
+ process.stderr.write(`${messageOf(e).slice(0, 600)}\n`);
210
+ process.exit(1);
211
+ });
@@ -0,0 +1,267 @@
1
+ // THE `check` COMMAND GROUP: connect, run, pull, bind, watch, status, doctor, clear, setup
2
+ // Loaded only when the first argument is `check`, so the hooks and the MCP forwarder never load any of it (and never load Playwright).
3
+ // Nothing here waits on a terminal: every command takes its input from flags and ends on its own.
4
+ // -- DEP-MODULES -- //
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname, relative, resolve } from 'node:path';
8
+ // -- MODULES -- //
9
+ import { bindFolder } from './bind.js';
10
+ import { ensureBrowser } from './browser.js';
11
+ import { connectChecks, loadRunnerToken, runnerAccount } from './connect.js';
12
+ import { forgetConnection, recordConnection } from './connections.js';
13
+ import { ORG_SLUG, scriptPath, scriptsDir } from './paths.js';
14
+ import { pinFiles, resolveProjectConfig } from './recipe.js';
15
+ import { runCheck } from './run.js';
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';
23
+ // -- CONSTANTS -- //
24
+ export const CHECK_USAGE = [
25
+ 'Usage: agent-flow-board check <command>',
26
+ ' connect --server <url> --org <slug> [--project KEY,KEY] [--json] make this machine a runner (an agent approves the code)',
27
+ ' run <item> <key> --server <url> --org <slug> [--step 1.2] [--run <id>] [--dir <repo>] [--json] run one check in this repo',
28
+ ' pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>] read the board copy of a script; accept it by hash',
29
+ ' bind --org <slug> --project <KEY> [--dir <repo>] remember the folder of a board project for "check watch"',
30
+ ' watch [--parallel N] [--once] take queued runs from the boards this machine is connected to',
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>'
37
+ ].join('\n');
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
+ }
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. */
49
+ export async function checkMain(ctx) {
50
+ const { args, log, out } = ctx;
51
+ const sub = args[0];
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
+ };
61
+ const has = (name) => args.includes(name);
62
+ const json = has('--json');
63
+ const need = (name) => {
64
+ const v = flag(name);
65
+ if (!v || v.startsWith('--'))
66
+ throw new Error(`Say ${name} <value>.`);
67
+ return v;
68
+ };
69
+ const server = () => {
70
+ const s = flag('--server') ?? process.env.AFB_SERVER;
71
+ if (!s)
72
+ throw new Error('Say which board to use: --server https://board.example.com (or set AFB_SERVER).');
73
+ return s.replace(/\/$/, '');
74
+ };
75
+ const org = () => {
76
+ const o = need('--org');
77
+ if (!ORG_SLUG.test(o))
78
+ throw new Error('--org takes an organisation slug: lowercase letters, digits and dashes.');
79
+ return o;
80
+ };
81
+ try {
82
+ if (sub === 'connect') {
83
+ const s = server();
84
+ const o = org();
85
+ const projects = flag('--project')
86
+ ?.split(',')
87
+ .map((p) => p.trim())
88
+ .filter(Boolean);
89
+ const done = await connectChecks({
90
+ server: s,
91
+ org: o,
92
+ projects,
93
+ json,
94
+ store: ctx.store,
95
+ fetch: ctx.fetch,
96
+ log,
97
+ out
98
+ });
99
+ recordConnection({ server: s, org: o, name: done.name });
100
+ return 0;
101
+ }
102
+ if (sub === 'disconnect') {
103
+ const s = server();
104
+ const o = org();
105
+ ctx.store.delete(runnerAccount(s, o));
106
+ forgetConnection(s, o);
107
+ log('Disconnected. The runner on the board is removed from the board settings.');
108
+ return 0;
109
+ }
110
+ if (sub === 'run') {
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
+ }
125
+ if (!item || !key || item.startsWith('--') || key.startsWith('--'))
126
+ throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug> (or --file <script>)');
127
+ const s = server();
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.`);
132
+ const token = loadRunnerToken(ctx.store, s, o);
133
+ if (!token)
134
+ throw new Error(`This machine is not connected to ${o} on ${s}. Run: agent-flow-board check connect --server ${s} --org ${o} --json`);
135
+ const outcome = await runCheck({
136
+ server: s,
137
+ org: o,
138
+ item,
139
+ key,
140
+ dir: resolve(flag('--dir') ?? process.cwd()),
141
+ step: flag('--step'),
142
+ runId: flag('--run'),
143
+ registration: flag('--registration'),
144
+ runnerToken: token,
145
+ personalToken: ctx.personalToken(s, o),
146
+ fetch: ctx.fetch,
147
+ log,
148
+ headed: has('--headed')
149
+ });
150
+ if (json)
151
+ out(JSON.stringify({
152
+ ok: outcome.exitCode === 0,
153
+ runId: outcome.runId,
154
+ message: outcome.message
155
+ }));
156
+ else
157
+ log(outcome.message);
158
+ // The last line is always the result file, so an agent can read it
159
+ if (outcome.resultPath)
160
+ out(`RESULT ${outcome.resultPath}`);
161
+ return outcome.exitCode;
162
+ }
163
+ if (sub === 'pull') {
164
+ const item = args[1];
165
+ const key = args[2];
166
+ if (!item || !key)
167
+ throw new Error('Usage: agent-flow-board check pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>]');
168
+ const s = server();
169
+ const o = org();
170
+ const personal = ctx.personalToken(s, o);
171
+ if (!personal)
172
+ throw new Error(`Not signed in to ${o} on ${s}. Run: agent-flow-board login --json --server ${s} --org ${o}`);
173
+ const res = await ctx.fetch(`${s}/api/v1/orgs/${encodeURIComponent(o)}/items/${encodeURIComponent(item)}/browser-checks`, {
174
+ headers: { authorization: `Bearer ${personal}` },
175
+ signal: AbortSignal.timeout(30_000)
176
+ });
177
+ const body = (await res.json().catch(() => ({})));
178
+ if (!res.ok)
179
+ throw new Error(body.error?.message ?? `The board answered ${res.status}.`);
180
+ const script = body.checks?.find((c) => c.key === key)?.script;
181
+ if (!script)
182
+ throw new Error(`The board has no saved script for ${item} ${key}.`);
183
+ const hash = createHash('sha256').update(script).digest('hex');
184
+ const target = scriptPath(o, item, key);
185
+ const local = existsSync(target) ? readFileSync(target, 'utf8') : null;
186
+ const accept = flag('--accept-hash');
187
+ if (accept) {
188
+ if (accept !== hash)
189
+ throw new Error('The board script changed since you read it (its hash is different), so nothing was written. Pull again.');
190
+ mkdirSync(dirname(target), { recursive: true });
191
+ writeFileSync(target, script);
192
+ log(`Wrote ${target}.`);
193
+ return 0;
194
+ }
195
+ // The script is text written by someone else: it is shown as data and never run until it is accepted by its hash
196
+ out(JSON.stringify({
197
+ item,
198
+ key,
199
+ hash,
200
+ local: local === null ? 'none' : local === script ? 'same' : 'different',
201
+ target,
202
+ script
203
+ }));
204
+ return 0;
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 });
225
+ if (sub === 'bind') {
226
+ const o = org();
227
+ const project = need('--project');
228
+ const dir = resolve(flag('--dir') ?? process.cwd());
229
+ const cfg = resolveProjectConfig(dir);
230
+ bindFolder(o, project, cfg.repo, pinFiles(cfg.repo));
231
+ log(`Bound ${o}/${project} to ${cfg.repo}.`);
232
+ return 0;
233
+ }
234
+ if (sub === 'watch') {
235
+ const ac = new AbortController();
236
+ const stop = () => ac.abort();
237
+ process.once('SIGINT', stop);
238
+ process.once('SIGTERM', stop);
239
+ const n = await watchChecks({
240
+ store: ctx.store,
241
+ fetch: ctx.fetch,
242
+ log,
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')),
245
+ once: has('--once'),
246
+ signal: ac.signal
247
+ });
248
+ log(`Took ${n} run(s).`);
249
+ return 0;
250
+ }
251
+ if (sub === 'setup') {
252
+ await ensureBrowser(log);
253
+ log('The browser is ready.');
254
+ return 0;
255
+ }
256
+ log(CHECK_USAGE);
257
+ return 2;
258
+ }
259
+ catch (e) {
260
+ const message = e instanceof Error ? e.message : String(e);
261
+ if (json)
262
+ out(JSON.stringify({ ok: false, event: 'error', error: message }));
263
+ else
264
+ log(message);
265
+ return 2;
266
+ }
267
+ }