@botbuddy/cli 1.60.5 → 1.60.6

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.
@@ -0,0 +1,482 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 cli.mjs): `bb staging-pool`, the
2
+ // human-owned staging session pool broker.
3
+ //
4
+ // install install + start the launchd job (human only, from Terminal.app)
5
+ // uninstall stop the job and delete its session files (human only)
6
+ // tick refresh unclaimed slots, refill to K, purge old takes (job / human only)
7
+ // mint-one one real sign-in into the pool (human only; spine check)
8
+ // status metadata only, or --check for one health reason (anyone)
9
+ // probe list_totp_seeds through the botbuddy CLI; never mints a code (anyone)
10
+ // print-plist show the plist install would write (anyone)
11
+ //
12
+ // Agents take sessions only through `bb pw --environment staging <lane> auth`
13
+ // (takeForLane below): a session is handed over by installing it into a claimed
14
+ // lane, never as a reusable file path. The configuration is the repo's committed
15
+ // `.botbuddy-agent.json` `staging_session_pool` binding; the installed job keeps a
16
+ // validated snapshot. Node-stdlib-only and self-contained (install copies this
17
+ // directory to <pool home>/app and the job runs that copy).
18
+
19
+ import { execFile } from "node:child_process";
20
+ import * as nodeFs from "node:fs/promises";
21
+ import { readFileSync, realpathSync } from "node:fs";
22
+ import { homedir, userInfo } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { promisify } from "node:util";
26
+
27
+ import { BindingError, findPersona, readRepoPoolBinding } from "./binding.mjs";
28
+ import { ConfigError, DEFAULT_INTERVAL_SECONDS, buildInstalledConfig, poolHomePaths, readInstalledConfig, serialiseInstalledConfig } from "./config.mjs";
29
+ import { assessPoolHealth, healthReasons } from "./health.mjs";
30
+ import { buildLaunchAgentPlist, createLaunchctl } from "./launchd.mjs";
31
+ import { createEnvFilePasswordSource } from "./password-source.mjs";
32
+ import { MintRefusedError, assertMintAllowed, assertPersonaAllowed, listProcessAncestors, stagingTargetFromBinding } from "./policy.mjs";
33
+ import { PoolEmptyError, createSessionPool } from "./pool.mjs";
34
+ import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
35
+ import { createRedactor } from "./state-file.mjs";
36
+ import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
37
+ import { formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
38
+ import { STAGING_POOL_HELP } from "./help.mjs";
39
+
40
+ export const BUNDLE_FILES = Object.freeze([
41
+ "binding.mjs",
42
+ "cli.mjs",
43
+ "config.mjs",
44
+ "errors.mjs",
45
+ "health.mjs",
46
+ "help.mjs",
47
+ "job.mjs",
48
+ "launchd.mjs",
49
+ "password-source.mjs",
50
+ "policy.mjs",
51
+ "pool.mjs",
52
+ "signin-gotrue-totp.mjs",
53
+ "state-file.mjs",
54
+ "totp-botbuddy.mjs",
55
+ ]);
56
+
57
+ const VALUE_FLAGS = new Set(["persona", "home", "botbuddy-bin", "interval", "owner"]);
58
+ const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check"]);
59
+
60
+ export function parseArgs(argv) {
61
+ const [command, ...rest] = argv;
62
+ const options = {};
63
+ for (let index = 0; index < rest.length; index += 1) {
64
+ const token = rest[index];
65
+ if (!token.startsWith("--")) throw new ConfigError(`Unexpected argument ${token}.`, "usage");
66
+ const [flag, inline] = token.slice(2).split(/=(.*)/s, 2);
67
+ if (BOOLEAN_FLAGS.has(flag)) {
68
+ options[flag] = true;
69
+ continue;
70
+ }
71
+ if (!VALUE_FLAGS.has(flag)) throw new ConfigError(`Unknown flag --${flag}.`, "usage");
72
+ const value = inline ?? rest[(index += 1)];
73
+ if (value === undefined || value === "") throw new ConfigError(`--${flag} needs a value.`, "usage");
74
+ options[flag] = value;
75
+ }
76
+ return { command, options };
77
+ }
78
+
79
+ export function exitCodeFor(error) {
80
+ if (error instanceof MintRefusedError || error?.code === "persona_not_allowed") return 2;
81
+ if (error instanceof PoolEmptyError) return 3;
82
+ if (error instanceof ConfigError || error instanceof BindingError || error?.code === "config_missing") return 4;
83
+ if (error?.code === "pool_unhealthy") return 5;
84
+ return 1;
85
+ }
86
+
87
+ // Where this invocation's pool lives: the job passes --home; everyone else
88
+ // resolves it from the repo binding (cwd).
89
+ async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding }) {
90
+ if (options.home) {
91
+ const paths = poolHomePaths({ home, root: options.home });
92
+ const config = readInstalledConfig(paths.config);
93
+ return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding };
94
+ }
95
+ const found = await readBinding(cwd);
96
+ if (!found) {
97
+ throw new ConfigError("This repository declares no staging_session_pool in .botbuddy-agent.json, so it has no staging session pool.", "pool_not_configured");
98
+ }
99
+ const paths = poolHomePaths({ home, poolId: found.binding.poolId });
100
+ let installed = null;
101
+ try {
102
+ installed = readInstalledConfig(paths.config);
103
+ } catch (error) {
104
+ if (error?.code !== "config_missing") installed = { error };
105
+ }
106
+ return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot };
107
+ }
108
+
109
+ const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
110
+ export const installCommandFor = (repoRoot, fallback) =>
111
+ repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : fallback;
112
+
113
+ // The non-secret values every guidance template may name.
114
+ export function guidanceVars(ctx, extra = {}) {
115
+ if (!ctx) return { ...extra };
116
+ return {
117
+ label: ctx.poolBinding.launchdLabel,
118
+ log: ctx.paths.log,
119
+ installCommand: installCommandFor(ctx.repoRoot, ctx.poolBinding.installCommand),
120
+ docs: ctx.poolBinding.docs,
121
+ configPath: ctx.paths.config,
122
+ poolHome: ctx.paths.root,
123
+ tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
124
+ checkout: ctx.repoRoot,
125
+ envFile: ctx.poolBinding.passwordEnvFile,
126
+ allowed: Object.keys(ctx.poolBinding.personas ?? {}),
127
+ ...extra,
128
+ };
129
+ }
130
+
131
+ function reasonsFor(ctx) {
132
+ return healthReasons(guidanceVars(ctx));
133
+ }
134
+
135
+ function buildPool({ ctx, target, signIn, now, logger, intervalSeconds, fetchImpl, sleep }) {
136
+ return createSessionPool({
137
+ poolDir: ctx.paths.pool,
138
+ target,
139
+ signIn: signIn ?? createGoTrueTotpSignIn({
140
+ fetchImpl,
141
+ totpProvider: async () => {
142
+ throw new Error("take/status never mint");
143
+ },
144
+ passwordFor: async () => {
145
+ throw new Error("take/status never mint");
146
+ },
147
+ logger,
148
+ sleep,
149
+ nowMs: now,
150
+ }),
151
+ now,
152
+ logger,
153
+ ...(intervalSeconds ? { intervalMs: intervalSeconds * 1000 } : {}),
154
+ tickIntervalHint: intervalSeconds ? ` (every ${Math.round(intervalSeconds / 60)} min)` : "",
155
+ });
156
+ }
157
+
158
+ export async function assessHealth({ ctx, pool, now, exec, uid, requestedPersona }) {
159
+ const reasons = reasonsFor(ctx);
160
+ if (!ctx.installed) {
161
+ return assessPoolHealth({ reasons, jobLoaded: false, status: null, personas: {}, intervalSeconds: DEFAULT_INTERVAL_SECONDS, nowMs: now() });
162
+ }
163
+ if (ctx.installed.error) {
164
+ return assessPoolHealth({ reasons, configError: String(ctx.installed.error.message), status: null, personas: {}, intervalSeconds: DEFAULT_INTERVAL_SECONDS, nowMs: now() });
165
+ }
166
+ const jobLoaded = await createLaunchctl({ exec, uid }).isLoaded(ctx.poolBinding.launchdLabel);
167
+ return {
168
+ ...assessPoolHealth({
169
+ reasons,
170
+ status: await pool.status(),
171
+ personas: ctx.installed.personas,
172
+ intervalSeconds: ctx.installed.intervalSeconds,
173
+ nowMs: now(),
174
+ jobLoaded,
175
+ requestedPersona,
176
+ }),
177
+ intervalSeconds: ctx.installed.intervalSeconds,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Agent path, used by `bb pw … auth`: claim one verified session for a persona
183
+ * (alias or email) of this repo's binding. Returns { taken, target, binding,
184
+ * discard } — the caller installs taken.statePath into its claimed lane and then
185
+ * MUST call discard() (single use). Throws PoolEmptyError carrying the health
186
+ * reason/agentAction/remedy when nothing is ready.
187
+ */
188
+ export async function takeForLane({
189
+ persona,
190
+ owner,
191
+ cwd = process.cwd(),
192
+ home = homedir(),
193
+ now = () => Date.now(),
194
+ fetchImpl = globalThis.fetch,
195
+ exec = promisify(execFile),
196
+ uid = userInfo().uid,
197
+ logger = () => {},
198
+ readBinding = readRepoPoolBinding,
199
+ } = {}) {
200
+ const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
201
+ const entry = findPersona(ctx.poolBinding, persona);
202
+ const target = stagingTargetFromBinding(ctx.poolBinding);
203
+ if (!entry) assertPersonaAllowed(target, persona);
204
+ const intervalSeconds = ctx.installed && !ctx.installed.error ? ctx.installed.intervalSeconds : undefined;
205
+ const pool = buildPool({ ctx, target, now, logger, intervalSeconds, fetchImpl });
206
+ try {
207
+ const taken = await pool.take({ persona: entry.email, owner });
208
+ return { taken, target, binding: ctx.poolBinding, persona: entry, discard: () => pool.discardTaken(taken.slotId) };
209
+ } catch (error) {
210
+ if (!(error instanceof PoolEmptyError)) throw error;
211
+ const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry.email });
212
+ let effective = health;
213
+ if (health.ok) {
214
+ const g = guidance("pool_empty", guidanceVars(ctx, { persona: entry.alias }));
215
+ effective = { ...health, reason: "pool_empty", agentAction: g.agent_action, remedy: remedyLine(g), message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help };
216
+ }
217
+ for (const field of GUIDANCE_FIELDS) if (effective[field] !== undefined) error[field] = effective[field];
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ // Fields a refusal carries so a caller can act on it without reading the code.
223
+ export const GUIDANCE_FIELDS = Object.freeze(["reason", "agentAction", "remedy", "message", "cause", "actor", "next", "doc", "help", "detail", "intervalSeconds"]);
224
+
225
+ // The human-only command line to repeat from Terminal.app (no secrets: flags only).
226
+ function humanCommandLine(argv) {
227
+ if (argv[0] === "install") return 'install --botbuddy-bin "$(command -v botbuddy)"';
228
+ return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
229
+ }
230
+
231
+ export async function runStagingPool(argv, deps = {}) {
232
+ const {
233
+ env = process.env,
234
+ stdout = (line) => process.stdout.write(`${line}\n`),
235
+ stderr = (line) => process.stderr.write(`${line}\n`),
236
+ cwd = process.cwd(),
237
+ home = homedir(),
238
+ uid = userInfo().uid,
239
+ exec = promisify(execFile),
240
+ listAncestors = listProcessAncestors,
241
+ fetchImpl = globalThis.fetch,
242
+ fs = nodeFs,
243
+ now = () => Date.now(),
244
+ scriptDir = dirname(fileURLToPath(import.meta.url)),
245
+ nodeBin = process.execPath,
246
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
247
+ readBinding = readRepoPoolBinding,
248
+ readSecretFile = readFileSync,
249
+ } = deps;
250
+
251
+ const redactor = createRedactor();
252
+ const emit = (record) => stdout(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
253
+
254
+ function minterFor(ctx, target) {
255
+ const binding = ctx.poolBinding;
256
+ const botbuddyBin = ctx.installed?.botbuddyBin;
257
+ if (!botbuddyBin) throw new ConfigError("The pool job is not installed (no botbuddyBin); the human runs the install first.", "pool_not_installed");
258
+ const password = createEnvFilePasswordSource({ envFile: binding.passwordEnvFile, personas: binding.personas, home, read: readSecretFile });
259
+ // Register the password with the redactor before any log line can carry it.
260
+ const passwordFor = async (args) => {
261
+ const value = await password(args);
262
+ redactor.register(value);
263
+ return value;
264
+ };
265
+ return createGoTrueTotpSignIn({
266
+ fetchImpl,
267
+ totpProvider: createBotBuddyTotpProvider({ exec, botbuddyBin, env, tenant: binding.tenant, appSlug: binding.appSlug }),
268
+ passwordFor,
269
+ logger: emit,
270
+ sleep,
271
+ nowMs: now,
272
+ });
273
+ }
274
+
275
+ function requireBotbuddyBin(options) {
276
+ const bin = options["botbuddy-bin"];
277
+ if (!bin) throw new ConfigError('install needs --botbuddy-bin "$(command -v botbuddy)".', "botbuddy_bin_required");
278
+ if (!bin.startsWith("/")) throw new ConfigError("--botbuddy-bin must be an absolute path.", "botbuddy_bin_required");
279
+ // fnm's per-shell PATH dirs disappear with the shell; the job outlives it.
280
+ return realpathSync(bin);
281
+ }
282
+
283
+ async function copyBundle(destination) {
284
+ await fs.mkdir(destination, { recursive: true, mode: 0o700 });
285
+ for (const file of BUNDLE_FILES) {
286
+ await fs.copyFile(join(scriptDir, file), join(destination, file));
287
+ await fs.chmod(join(destination, file), 0o600);
288
+ }
289
+ // The bundle is ESM; mark it so whatever package.json sits above the pool home is irrelevant.
290
+ await fs.writeFile(join(destination, "package.json"), '{ "type": "module" }\n', { mode: 0o600 });
291
+ }
292
+
293
+ function jobPlist(ctx, intervalSeconds) {
294
+ return buildLaunchAgentPlist({
295
+ label: ctx.poolBinding.launchdLabel,
296
+ programArguments: [nodeBin, join(ctx.paths.app, "job.mjs"), "tick", "--home", ctx.paths.root],
297
+ environment: { PATH: `${dirname(nodeBin)}:/usr/bin:/bin`, HOME: home },
298
+ workingDirectory: ctx.paths.root,
299
+ intervalSeconds,
300
+ stdoutPath: ctx.paths.log,
301
+ stderrPath: ctx.paths.log,
302
+ });
303
+ }
304
+
305
+ const commands = {
306
+ async install(options) {
307
+ const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
308
+ const target = stagingTargetFromBinding(ctx.poolBinding);
309
+ assertMintAllowed({ target, env, listAncestors });
310
+ const botbuddyBin = requireBotbuddyBin(options);
311
+ for (const dir of [ctx.paths.root, ctx.paths.logDir]) {
312
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
313
+ await fs.chmod(dir, 0o700);
314
+ }
315
+ await copyBundle(ctx.paths.app);
316
+ const rawBinding = JSON.parse(await fs.readFile(join(ctx.repoRoot, ".botbuddy-agent.json"), "utf8"));
317
+ let existing = null;
318
+ try {
319
+ existing = JSON.parse(await fs.readFile(ctx.paths.config, "utf8"));
320
+ } catch (error) {
321
+ if (error?.code !== "ENOENT") throw error;
322
+ }
323
+ const explicitInterval = options.interval === undefined ? undefined : Number(options.interval);
324
+ const config = buildInstalledConfig({
325
+ rawBinding,
326
+ poolBinding: ctx.poolBinding,
327
+ botbuddyBin,
328
+ intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
329
+ existingPersonas: existing?.personas,
330
+ });
331
+ await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
332
+ await fs.chmod(ctx.paths.config, 0o600);
333
+ const plistPath = join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`);
334
+ await fs.mkdir(ctx.paths.plistDir, { recursive: true });
335
+ await fs.writeFile(plistPath, jobPlist(ctx, config.intervalSeconds), { mode: 0o644 });
336
+ const launchctl = createLaunchctl({ exec, uid });
337
+ await launchctl.bootout(ctx.poolBinding.launchdLabel);
338
+ await launchctl.bootstrap(plistPath);
339
+ emit({
340
+ ok: true,
341
+ command: "install",
342
+ label: ctx.poolBinding.launchdLabel,
343
+ plistPath,
344
+ poolHome: ctx.paths.root,
345
+ log: ctx.paths.log,
346
+ intervalSeconds: config.intervalSeconds,
347
+ personas: config.personas,
348
+ next: "bb staging-pool status --check # the pool fills within one tick",
349
+ });
350
+ return 0;
351
+ },
352
+
353
+ async uninstall(options) {
354
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
355
+ const target = stagingTargetFromBinding(ctx.poolBinding);
356
+ // The job and its sessions are the human's: an agent never stops or empties them.
357
+ assertMintAllowed({ target, env, listAncestors });
358
+ const launchctl = createLaunchctl({ exec, uid });
359
+ const wasLoaded = await launchctl.bootout(ctx.poolBinding.launchdLabel);
360
+ await fs.rm(join(ctx.paths.plistDir, `${ctx.poolBinding.launchdLabel}.plist`), { force: true });
361
+ let removedSessions = 0;
362
+ if (!options["keep-sessions"]) removedSessions = await buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep }).purge();
363
+ emit({ ok: true, command: "uninstall", label: ctx.poolBinding.launchdLabel, wasLoaded, removedSessions });
364
+ return 0;
365
+ },
366
+
367
+ async tick(options) {
368
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
369
+ const target = stagingTargetFromBinding(ctx.poolBinding);
370
+ assertMintAllowed({ target, env, listAncestors });
371
+ if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
372
+ const pool = buildPool({ ctx, target, signIn: minterFor(ctx, target), now, logger: emit, intervalSeconds: ctx.installed.intervalSeconds, fetchImpl, sleep });
373
+ const summary = await pool.tick({ personas: ctx.installed.personas });
374
+ return summary.mintFailures > 0 ? 1 : 0;
375
+ },
376
+
377
+ async "mint-one"(options) {
378
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
379
+ const target = stagingTargetFromBinding(ctx.poolBinding);
380
+ assertMintAllowed({ target, env, listAncestors });
381
+ const entry = findPersona(ctx.poolBinding, options.persona);
382
+ const persona = entry ? entry.email : assertPersonaAllowed(target, options.persona);
383
+ if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
384
+ const pool = buildPool({ ctx, target, signIn: minterFor(ctx, target), now, logger: emit, intervalSeconds: ctx.installed.intervalSeconds, fetchImpl, sleep });
385
+ const minted = await pool.mintOne({ persona });
386
+ emit({ ok: true, command: "mint-one", target: target.name, signIns: 1, ...minted.meta });
387
+ return 0;
388
+ },
389
+
390
+ async status(options) {
391
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
392
+ const target = stagingTargetFromBinding(ctx.poolBinding);
393
+ const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
394
+ if (options.check) {
395
+ const entry = options.persona ? findPersona(ctx.poolBinding, options.persona) : null;
396
+ if (options.persona && !entry) assertPersonaAllowed(target, options.persona);
397
+ const health = await assessHealth({ ctx, pool, now, exec, uid, requestedPersona: entry?.email });
398
+ emit({ command: "status", check: true, ...health });
399
+ return health.ok ? 0 : 5;
400
+ }
401
+ emit({ ok: true, command: "status", ...(await pool.status()) });
402
+ return 0;
403
+ },
404
+
405
+ async probe(options) {
406
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
407
+ const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
408
+ if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
409
+ const receipt = await probeBotBuddy({
410
+ exec,
411
+ botbuddyBin,
412
+ env,
413
+ tenant: ctx.poolBinding.tenant,
414
+ appSlug: ctx.poolBinding.appSlug,
415
+ environment: ctx.poolBinding.environment,
416
+ personaEmails: Object.values(ctx.poolBinding.personas).map((persona) => persona.email),
417
+ });
418
+ emit({ command: "probe", ...receipt });
419
+ return receipt.ok ? 0 : 5;
420
+ },
421
+
422
+ async "print-plist"(options) {
423
+ const ctx = await resolveContext({ options, cwd, home, readBinding });
424
+ stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
425
+ return 0;
426
+ },
427
+ };
428
+
429
+ // One JSON line (machine-readable) then the same guidance as text, on stderr.
430
+ async function fail(command, error, options = {}) {
431
+ const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
432
+ let ctx = null;
433
+ try {
434
+ ctx = await resolveContext({ options, cwd, home, readBinding });
435
+ } catch {
436
+ ctx = null;
437
+ }
438
+ const persona = options.persona ?? error?.persona;
439
+ const vars = guidanceVars(ctx, {
440
+ persona,
441
+ command: humanCommandLine(argv),
442
+ markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
443
+ checkout: ctx?.repoRoot ?? cwd,
444
+ retry: `bb staging-pool ${humanCommandLine(argv)}`,
445
+ });
446
+ const g = guidance(code, vars);
447
+ const record = {
448
+ ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
449
+ agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
450
+ };
451
+ // A pool refusal already carries its health guidance (pool_empty / unhealthy).
452
+ for (const field of GUIDANCE_FIELDS) if (error?.[field] !== undefined) record[field] = error[field];
453
+ record.remedy ??= remedyLine(g);
454
+ stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
455
+ // An empty pool is explained by its health reason (e.g. job_not_loaded).
456
+ const text = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars) : g;
457
+ stderr(redactor.redact(formatGuidance(text)));
458
+ return exitCodeFor(error);
459
+ }
460
+
461
+ let parsed;
462
+ try {
463
+ parsed = parseArgs(argv);
464
+ } catch (error) {
465
+ await fail(argv[0] ?? null, Object.assign(error, { code: "usage" }));
466
+ return 4;
467
+ }
468
+ if (parsed.command === undefined || parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || parsed.options.help) {
469
+ stdout(STAGING_POOL_HELP);
470
+ return 0;
471
+ }
472
+ const handler = commands[parsed.command];
473
+ if (!handler) {
474
+ await fail(parsed.command, new ConfigError(`Unknown subcommand ${parsed.command}.`, "usage"));
475
+ return 4;
476
+ }
477
+ try {
478
+ return await handler(parsed.options);
479
+ } catch (error) {
480
+ return fail(parsed.command, error, parsed.options);
481
+ }
482
+ }
@@ -0,0 +1,107 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120 config.mjs): pool home layout and
2
+ // the installed job's config. Node-stdlib-only and self-contained.
3
+ //
4
+ // ~/.botbuddy/staging-session-pool/<pool_id>/ (0700)
5
+ // config.json binding snapshot + persona counts, botbuddy CLI path, tick interval
6
+ // app/ the installed copy of these modules (the job never runs from a worktree)
7
+ // pool/ ready/ work/ taken/ (see pool.mjs)
8
+ // log/pool.log the job's redacted JSON-lines log
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { MAX_POOL_SIZE, parsePoolBinding } from "./binding.mjs";
15
+
16
+ export class ConfigError extends Error {
17
+ constructor(message, code = "config_missing") {
18
+ super(message);
19
+ this.name = "ConfigError";
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ export const DEFAULT_INTERVAL_SECONDS = 300;
25
+
26
+ export function poolHomePaths({ home = homedir(), poolId, root } = {}) {
27
+ const base = root ?? join(home, ".botbuddy", "staging-session-pool", String(poolId));
28
+ return {
29
+ root: base,
30
+ config: join(base, "config.json"),
31
+ app: join(base, "app"),
32
+ pool: join(base, "pool"),
33
+ logDir: join(base, "log"),
34
+ log: join(base, "log", "pool.log"),
35
+ plistDir: join(home, "Library", "LaunchAgents"),
36
+ };
37
+ }
38
+
39
+ // The installed config: { version, binding: <raw .botbuddy-agent.json subset>,
40
+ // personas: { email: count }, botbuddyBin, intervalSeconds }. Returns it with the
41
+ // parsed binding attached as `poolBinding`.
42
+ export function validateInstalledConfig(config) {
43
+ if (!config || typeof config !== "object") throw new ConfigError("Pool config is not an object.", "config_invalid");
44
+ if (config.version !== 1) throw new ConfigError("Pool config version must be 1.", "config_invalid");
45
+ let poolBinding;
46
+ try {
47
+ poolBinding = parsePoolBinding(config.binding);
48
+ } catch (error) {
49
+ throw new ConfigError(`Pool config binding is invalid: ${error.message}`, "config_invalid");
50
+ }
51
+ if (!poolBinding) throw new ConfigError("Pool config has no staging_session_pool binding.", "config_invalid");
52
+ if (typeof config.botbuddyBin !== "string" || !config.botbuddyBin.startsWith("/")) {
53
+ throw new ConfigError("Pool config botbuddyBin must be an absolute path (re-run install).", "config_invalid");
54
+ }
55
+ if (!Number.isInteger(config.intervalSeconds) || config.intervalSeconds < 60 || config.intervalSeconds > 3600) {
56
+ throw new ConfigError("Pool config intervalSeconds must be an integer 60-3600.", "config_invalid");
57
+ }
58
+ const allowed = new Set(Object.values(poolBinding.personas).map((persona) => persona.email));
59
+ const personas = config.personas;
60
+ if (!personas || typeof personas !== "object" || Object.keys(personas).length === 0) {
61
+ throw new ConfigError("Pool config personas must name at least one persona.", "config_invalid");
62
+ }
63
+ for (const [persona, count] of Object.entries(personas)) {
64
+ if (!allowed.has(persona)) throw new ConfigError(`Pool config persona ${persona} is not allowlisted by the binding.`, "config_invalid");
65
+ if (!Number.isInteger(count) || count < 0 || count > MAX_POOL_SIZE) {
66
+ throw new ConfigError(`Pool size for ${persona} must be an integer 0-${MAX_POOL_SIZE}.`, "config_invalid");
67
+ }
68
+ }
69
+ return { ...config, poolBinding };
70
+ }
71
+
72
+ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
73
+ let text;
74
+ try {
75
+ text = readFile(path, "utf8");
76
+ } catch (error) {
77
+ if (error?.code === "ENOENT") throw new ConfigError(`Pool config ${path} is missing: the pool job is not installed.`, "config_missing");
78
+ throw error;
79
+ }
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(text);
83
+ } catch {
84
+ throw new ConfigError(`Pool config ${path} is not valid JSON.`, "config_invalid");
85
+ }
86
+ return validateInstalledConfig(parsed);
87
+ }
88
+
89
+ // rawBinding: { tenant, staging_session_pool } exactly as committed.
90
+ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas }) {
91
+ const personas = {};
92
+ for (const persona of Object.values(poolBinding.personas)) {
93
+ personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
94
+ }
95
+ return validateInstalledConfig({
96
+ version: 1,
97
+ binding: { tenant: rawBinding.tenant, staging_session_pool: rawBinding.staging_session_pool },
98
+ personas,
99
+ botbuddyBin,
100
+ intervalSeconds,
101
+ });
102
+ }
103
+
104
+ export function serialiseInstalledConfig(config) {
105
+ const { poolBinding: _parsed, ...rest } = config;
106
+ return `${JSON.stringify(rest, null, 2)}\n`;
107
+ }