@gaia-ai/conductor 0.0.1

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,690 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CommandRunner, createLogger, exec, setDefaultCommandRunner, } from '@gaia-ai/core';
6
+ import { selectAgent, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
7
+ import { Command } from 'commander';
8
+ import { authStatus, buildProgram as buildDropshProgram } from 'dropsh';
9
+ import { loadConductorConfig, resolveConfigPath } from '../config.js';
10
+ import { Conductor } from '../core/conductor.js';
11
+ import { machineContextPath, readMachineContext, scaffold, } from './init.js';
12
+ import * as registry from './local-registry.js';
13
+ /**
14
+ * Default config path. `--config` / `$GAIA_CONDUCTOR_CONFIG` win; otherwise walk
15
+ * up from cwd to the nearest `.gaia/conductor.config.js` (git/eslint style), so a
16
+ * `gaia` command works from any subdirectory of a project/worktree. Throws an
17
+ * actionable error when nothing is found up the tree (see resolveConfigPath).
18
+ */
19
+ function defaultConfigPath(override) {
20
+ return resolveConfigPath(override);
21
+ }
22
+ async function resolveConfig(deps, configPathOverride) {
23
+ if (deps.config) {
24
+ return deps.config;
25
+ }
26
+ return loadConductorConfig(defaultConfigPath(configPathOverride));
27
+ }
28
+ async function resolveRemote(deps, config) {
29
+ return deps.remote ?? (await selectRemote(config));
30
+ }
31
+ function checkoutRootOf(config) {
32
+ return dirname(config.config_path);
33
+ }
34
+ /**
35
+ * The conductor's stable identity (gaia_conductor.machine_id). machine_id is
36
+ * required — the loader (loadConductorConfig) throws without it — so lifecycle
37
+ * commands read it here, never re-derive. A config not built by the loader that
38
+ * somehow lacks it fails loudly rather than silently minting a divergent id.
39
+ */
40
+ function conductorIdOf(config) {
41
+ const id = config.machine_id;
42
+ if (id === undefined || id.trim() === '') {
43
+ throw new Error('conductor config has no machine_id');
44
+ }
45
+ return id;
46
+ }
47
+ /** Read the `conductor`-level --log-level / --log-sink flags from a subcommand. */
48
+ function logOptsOf(cmd) {
49
+ const opts = cmd.optsWithGlobals();
50
+ return {
51
+ ...(opts.logLevel !== undefined ? { level: opts.logLevel } : {}),
52
+ ...(opts.logSink !== undefined ? { sink: opts.logSink } : {}),
53
+ };
54
+ }
55
+ /** Create the conductor logger (with CLI log overrides) and route all exec logging through it. */
56
+ function loggerFor(checkoutRoot, log = {}) {
57
+ const logger = createLogger({ checkoutRoot, ...log });
58
+ setDefaultCommandRunner(new CommandRunner(logger));
59
+ return logger;
60
+ }
61
+ // --- herdr host (untested: shells out to herdr) ---------------------------
62
+ export function parseHerdrJson(output, command) {
63
+ try {
64
+ return JSON.parse(output);
65
+ }
66
+ catch {
67
+ throw new Error(`herdr ${command} returned invalid JSON`);
68
+ }
69
+ }
70
+ /** Spawn a detached herdr pane running the given command in cwd. */
71
+ async function spawnViaHerdr(label, cwd, cmd) {
72
+ const created = await exec('herdr', [
73
+ 'workspace',
74
+ 'create',
75
+ '--cwd',
76
+ cwd,
77
+ '--label',
78
+ label,
79
+ '--no-focus',
80
+ ]);
81
+ const parsed = parseHerdrJson(created, 'workspace create');
82
+ const paneId = parsed.result?.root_pane?.pane_id;
83
+ if (!paneId) {
84
+ throw new Error('herdr workspace create returned no pane id');
85
+ }
86
+ await exec('herdr', ['pane', 'run', paneId, cmd]);
87
+ }
88
+ /** Hard-kill a herdr-hosted conductor by its pane label. */
89
+ async function killViaHerdr(label) {
90
+ const listed = await exec('herdr', ['workspace', 'list']);
91
+ const parsed = parseHerdrJson(listed, 'workspace list');
92
+ const ws = (parsed.result?.workspaces ?? []).find((w) => w.label === label);
93
+ if (ws?.workspace_id) {
94
+ await exec('herdr', ['workspace', 'close', ws.workspace_id]);
95
+ }
96
+ }
97
+ // --- ls/status freshness ----------------------------------------------------
98
+ const DEFAULT_FRESH_S = 120;
99
+ function freshnessThresholdS(config) {
100
+ return config
101
+ ? Math.max(DEFAULT_FRESH_S, config.lease_seconds * 2)
102
+ : DEFAULT_FRESH_S;
103
+ }
104
+ function classify(hub, freshS) {
105
+ // No host probe here (host calls are untested) → can't tell host-missing
106
+ // from wedged. registry+no-hub = registry-only; stale-hub = wedged.
107
+ if (!hub) {
108
+ return 'registry-only';
109
+ }
110
+ if (hub.status === 'offline') {
111
+ return 'stopped';
112
+ }
113
+ const nowS = Math.floor(Date.now() / 1000);
114
+ const fresh = hub.lastSeen > 0 && nowS - hub.lastSeen <= freshS;
115
+ return fresh ? 'running' : 'wedged';
116
+ }
117
+ async function buildLsRows(remote, config, onlyId) {
118
+ const entries = await registry.list();
119
+ let hub = [];
120
+ try {
121
+ hub = await remote.listConductors('me');
122
+ }
123
+ catch {
124
+ hub = [];
125
+ }
126
+ const hubById = new Map(hub.map((c) => [c.id, c]));
127
+ const freshS = freshnessThresholdS(config);
128
+ const ids = new Set();
129
+ for (const e of entries) {
130
+ ids.add(e.id);
131
+ }
132
+ for (const c of hub) {
133
+ ids.add(c.id);
134
+ }
135
+ const rows = [];
136
+ for (const id of ids) {
137
+ if (onlyId && id !== onlyId) {
138
+ continue;
139
+ }
140
+ const entry = entries.find((e) => e.id === id);
141
+ const h = hubById.get(id);
142
+ rows.push({
143
+ id,
144
+ project: entry?.project ?? h?.project ?? '',
145
+ label: entry?.label ?? h?.label ?? '',
146
+ host: entry?.host ?? '-',
147
+ status: classify(h, freshS),
148
+ });
149
+ }
150
+ return rows;
151
+ }
152
+ function printRows(rows) {
153
+ if (rows.length === 0) {
154
+ console.log('no conductors registered');
155
+ return;
156
+ }
157
+ for (const r of rows) {
158
+ console.log(`${r.id}\t${r.status}\t${r.host}\t${r.project}\t${r.label}`);
159
+ }
160
+ }
161
+ // --- command handlers -------------------------------------------------------
162
+ /**
163
+ * Start-time auth gate. Returns true if authenticated (session or session-less
164
+ * provider); otherwise logs a single clear line and returns false. Must run
165
+ * before remote resolution (resolveRemote), which calls resolveAuth and throws
166
+ * when unauthenticated.
167
+ */
168
+ export async function ensureAuthenticated(config, logger) {
169
+ const st = await authStatus({
170
+ baseUrl: config.site.base_url,
171
+ plugins: config.plugins ?? [],
172
+ });
173
+ if (!st.loggedIn) {
174
+ logger.error({ baseUrl: config.site.base_url }, `not logged in against ${config.site.base_url} — run 'gaia dropsh auth login'`);
175
+ return false;
176
+ }
177
+ return true;
178
+ }
179
+ async function cmdPoll(deps, log = {}) {
180
+ const config = await resolveConfig(deps);
181
+ const checkoutRoot = checkoutRootOf(config);
182
+ const logger = loggerFor(checkoutRoot, log);
183
+ if (!(await ensureAuthenticated(config, logger)))
184
+ return;
185
+ const remote = await resolveRemote(deps, config);
186
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
187
+ const workspace = deps.workspace ?? (await selectWorkspace(config));
188
+ const agent = deps.agent ?? (await selectAgent(config));
189
+ const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
190
+ await conductor.start();
191
+ await conductor.tick();
192
+ }
193
+ async function cmdReap(deps, log = {}) {
194
+ const config = await resolveConfig(deps);
195
+ const checkoutRoot = checkoutRootOf(config);
196
+ const logger = loggerFor(checkoutRoot, log);
197
+ if (!(await ensureAuthenticated(config, logger)))
198
+ return;
199
+ const remote = await resolveRemote(deps, config);
200
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
201
+ const workspace = deps.workspace ?? (await selectWorkspace(config));
202
+ const agent = deps.agent ?? (await selectAgent(config));
203
+ const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
204
+ // The reaper reconciles finished-but-uncleaned tickets (the cleaned_up flag)
205
+ // against their worktrees; it needs no registration/heartbeat (it is not a
206
+ // poll), just the executor + remote, so it runs standalone after a
207
+ // crash/restart.
208
+ await conductor.reap();
209
+ }
210
+ async function cmdStartForeground(deps, log = {}) {
211
+ const config = await resolveConfig(deps);
212
+ const checkoutRoot = checkoutRootOf(config);
213
+ const logger = loggerFor(checkoutRoot, log);
214
+ if (!(await ensureAuthenticated(config, logger)))
215
+ return;
216
+ const remote = await resolveRemote(deps, config);
217
+ const executor = deps.executor ?? (await selectExecutor(config, logger));
218
+ const workspace = deps.workspace ?? (await selectWorkspace(config));
219
+ const agent = deps.agent ?? (await selectAgent(config));
220
+ const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
221
+ await conductor.start();
222
+ const controller = new AbortController();
223
+ const onSignal = () => controller.abort();
224
+ process.once('SIGINT', onSignal);
225
+ process.once('SIGTERM', onSignal);
226
+ try {
227
+ await conductor.serve(controller.signal);
228
+ }
229
+ finally {
230
+ process.removeListener('SIGINT', onSignal);
231
+ process.removeListener('SIGTERM', onSignal);
232
+ }
233
+ }
234
+ async function cmdStart(deps, log = {}) {
235
+ const config = await resolveConfig(deps);
236
+ const checkoutRoot = checkoutRootOf(config);
237
+ const logger = loggerFor(checkoutRoot, log);
238
+ if (!(await ensureAuthenticated(config, logger)))
239
+ return;
240
+ const remote = await resolveRemote(deps, config);
241
+ const id = conductorIdOf(config);
242
+ const existing = await registry.get(id);
243
+ if (existing) {
244
+ const hubStatus = await remote.getConductorStatus(id);
245
+ if (hubStatus !== null && hubStatus !== 'offline') {
246
+ console.log(`conductor already running for ${config.project}`);
247
+ return;
248
+ }
249
+ }
250
+ const handle = `gaia-conductor:${id}`;
251
+ await registry.register({
252
+ id,
253
+ path: checkoutRoot,
254
+ project: config.project,
255
+ label: config.label,
256
+ host: 'herdr',
257
+ handle,
258
+ });
259
+ // The detached foreground process is a fresh CLI invocation — forward the
260
+ // log flags so the herdr-hosted loop logs at the requested level. Sink stays
261
+ // forced to file (herdr-hosted = no TTY) unless the caller overrode it.
262
+ const fgFlags = [
263
+ log.level ? `--log-level ${log.level}` : '',
264
+ log.sink ? `--log-sink ${log.sink}` : '',
265
+ ]
266
+ .filter(Boolean)
267
+ .join(' ');
268
+ const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground`.replace(/\s+/g, ' ');
269
+ try {
270
+ await spawnViaHerdr(handle, checkoutRoot, fgCmd);
271
+ logger.info({ id, handle }, 'started conductor via herdr');
272
+ }
273
+ catch (err) {
274
+ logger.error({ id, err: err.message }, 'could not start via herdr');
275
+ logger.warn({}, 'Hint: run `gaia conductor start --foreground` under a service manager (systemd-user / docker) on hosts without herdr.');
276
+ }
277
+ }
278
+ async function cmdStop(deps, now) {
279
+ const config = await resolveConfig(deps);
280
+ const remote = await resolveRemote(deps, config);
281
+ const id = conductorIdOf(config);
282
+ if (now) {
283
+ const entry = await registry.get(id);
284
+ if (entry && entry.host === 'herdr') {
285
+ await killViaHerdr(entry.handle);
286
+ console.log(`hard-stopped conductor ${id} (${entry.handle})`);
287
+ }
288
+ else {
289
+ console.log(`no herdr-hosted conductor to hard-stop for ${id}`);
290
+ }
291
+ // A hard kill just stops the heartbeat; it never deletes the entity. The
292
+ // Drupal cron reaper flips the now-stale registration to offline (single
293
+ // authority for the offline transition — see gaia_core cron).
294
+ return;
295
+ }
296
+ // Graceful stop: there is no drain phase — the conductor goes offline at once,
297
+ // just like a hard kill, but writes offline itself instead of waiting for the
298
+ // cron reaper. Stop the process first (else its next heartbeat would flip it
299
+ // back online), then mark it offline. In-flight runs are not awaited.
300
+ const entry = await registry.get(id);
301
+ if (entry && entry.host === 'herdr') {
302
+ await killViaHerdr(entry.handle);
303
+ }
304
+ await remote.setConductorStatus(id, 'offline');
305
+ console.log(`stopped conductor ${id} (offline)`);
306
+ }
307
+ async function cmdLs(deps) {
308
+ const config = deps.config ?? (await tryConfig(deps));
309
+ const remote = await resolveRemote(deps, config ?? (await resolveConfig(deps)));
310
+ const rows = await buildLsRows(remote, config);
311
+ printRows(rows);
312
+ }
313
+ async function cmdStatus(deps) {
314
+ const config = await resolveConfig(deps);
315
+ const remote = await resolveRemote(deps, config);
316
+ const id = conductorIdOf(config);
317
+ const rows = await buildLsRows(remote, config, id);
318
+ printRows(rows);
319
+ }
320
+ async function cmdRm(deps) {
321
+ const config = await resolveConfig(deps);
322
+ const id = conductorIdOf(config);
323
+ await registry.remove(id);
324
+ console.log(`removed conductor ${id} from registry`);
325
+ }
326
+ /** ls may run without a config file; best-effort load. */
327
+ async function tryConfig(deps) {
328
+ if (deps.config) {
329
+ return deps.config;
330
+ }
331
+ try {
332
+ return await loadConductorConfig(defaultConfigPath());
333
+ }
334
+ catch {
335
+ return undefined;
336
+ }
337
+ }
338
+ /** Prompt for the developer Kürzel / user id on an interactive terminal. */
339
+ async function promptUserId() {
340
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
341
+ try {
342
+ const answer = await new Promise((resolve) => rl.question('Your Kürzel / user id: ', resolve));
343
+ return answer.trim();
344
+ }
345
+ finally {
346
+ rl.close();
347
+ }
348
+ }
349
+ /** Prompt for the OAuth client secret without echoing the typed characters. */
350
+ async function promptSecret() {
351
+ const rl = createInterface({
352
+ input: process.stdin,
353
+ output: process.stdout,
354
+ terminal: true,
355
+ });
356
+ // Mute character echo while the secret is typed.
357
+ rl._writeToOutput = (s) => {
358
+ if (!rl.muted || s.includes('\n'))
359
+ process.stdout.write(s);
360
+ };
361
+ try {
362
+ const answer = await new Promise((resolve) => {
363
+ rl.question('OAuth client secret: ', (a) => {
364
+ process.stdout.write('\n');
365
+ resolve(a);
366
+ });
367
+ rl.muted = true;
368
+ });
369
+ return answer.trim();
370
+ }
371
+ finally {
372
+ rl.close();
373
+ }
374
+ }
375
+ // --- program ----------------------------------------------------------------
376
+ // Report the CLI's own package version. Walks up from this module to the
377
+ // nearest @gaia-ai/conductor package.json so it resolves both from the
378
+ // compiled dist/src/cli/gaia.js (3 dirs up) and the src/cli/gaia.ts vitest
379
+ // runs (2 dirs up). Same release tag across packages ⇒ == @gaia-ai/gaia.
380
+ function resolveCliVersion() {
381
+ let dir = dirname(fileURLToPath(import.meta.url));
382
+ for (let i = 0; i < 6; i++) {
383
+ try {
384
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
385
+ if (pkg.name === '@gaia-ai/conductor')
386
+ return pkg.version ?? '0.0.0';
387
+ }
388
+ catch {
389
+ // no package.json here — keep walking up
390
+ }
391
+ dir = dirname(dir);
392
+ }
393
+ return '0.0.0';
394
+ }
395
+ export function buildProgram(deps) {
396
+ const program = new Command();
397
+ program
398
+ .name('gaia')
399
+ .description('GAIA conductor + client CLI')
400
+ .option('--conductor <name>', 'select a named .gaia/<name>.config.js (default: the sole config; env $GAIA_CONDUCTOR)');
401
+ // GAIA-126: a repo may hold several named .gaia/<stem>.config.js conductors.
402
+ // resolveConfigPath already honours $GAIA_CONDUCTOR; thread the global
403
+ // --conductor flag into it (flag wins over env) so every command selects the
404
+ // named config without per-command wiring. A per-invocation flag beats any
405
+ // ambient env for this run.
406
+ program.hook('preAction', () => {
407
+ const name = program.opts().conductor;
408
+ if (typeof name === 'string' && name !== '') {
409
+ process.env.GAIA_CONDUCTOR = name;
410
+ }
411
+ });
412
+ const cliVersion = resolveCliVersion();
413
+ program.version(cliVersion); // -V, --version
414
+ program
415
+ .command('version')
416
+ .description('output the gaia CLI version')
417
+ .action(() => {
418
+ console.log(cliVersion);
419
+ });
420
+ const conductor = program
421
+ .command('conductor')
422
+ .description('node-agent lifecycle + local registry')
423
+ .option('--log-level <level>', 'log verbosity: debug | info | warn | error (overrides GAIA_LOG_LEVEL)')
424
+ .option('--log-sink <sink>', 'log sink: stdout | file (overrides GAIA_CONDUCTOR_LOG)')
425
+ .addHelpText('after', `
426
+ Logging (precedence: CLI flag > env var > default):
427
+ --log-level <level> debug | info (default) | warn | error.
428
+ Set debug to see per-poll ticks, claims, and idle cycles.
429
+ --log-sink <sink> stdout | file (writes <checkoutRoot>/log.txt).
430
+ Default: stdout on a TTY, file otherwise.
431
+ GAIA_LOG_LEVEL env fallback for --log-level.
432
+ GAIA_CONDUCTOR_LOG env fallback for --log-sink.
433
+
434
+ Examples:
435
+ gaia conductor --log-level debug start --foreground
436
+ gaia conductor --log-level debug poll
437
+ GAIA_LOG_LEVEL=debug gaia conductor poll`);
438
+ conductor
439
+ .command('start')
440
+ .description('start the conductor loop (herdr-hosted by default)')
441
+ .option('--foreground', 'run the loop in this process', false)
442
+ .action(async function (opts) {
443
+ if (opts.foreground) {
444
+ await cmdStartForeground(deps, logOptsOf(this));
445
+ }
446
+ else {
447
+ await cmdStart(deps, logOptsOf(this));
448
+ }
449
+ });
450
+ conductor
451
+ .command('poll')
452
+ .description('run one conductor cycle then exit')
453
+ .action(async function () {
454
+ await cmdPoll(deps, logOptsOf(this));
455
+ });
456
+ conductor
457
+ .command('reap')
458
+ .description('tear down herdr worktrees of finished-but-uncleaned tickets (recovers orphans a live tick missed: conductor was down at done, restarted, or reassigned)')
459
+ .action(async function () {
460
+ await cmdReap(deps, logOptsOf(this));
461
+ });
462
+ conductor
463
+ .command('stop')
464
+ .description('stop the conductor: graceful offline (default) or hard kill (--now)')
465
+ .option('--now', 'hard-kill via host and let the cron reaper mark it offline', false)
466
+ .action(async (opts) => {
467
+ await cmdStop(deps, opts.now);
468
+ });
469
+ conductor
470
+ .command('ls')
471
+ .description('list conductors on this machine + status')
472
+ .action(async () => {
473
+ await cmdLs(deps);
474
+ });
475
+ conductor
476
+ .command('status')
477
+ .description('status of the conductor for this checkout')
478
+ .action(async () => {
479
+ await cmdStatus(deps);
480
+ });
481
+ conductor
482
+ .command('rm')
483
+ .description('deregister the conductor for this checkout')
484
+ .action(async () => {
485
+ await cmdRm(deps);
486
+ });
487
+ conductor
488
+ .command('init')
489
+ .description('scaffold the committed .gaia/conductor.config.js for this repo plus the user-global conductor.config.machine.js context (identity + connection incl. secret)')
490
+ .option('--base-url <url>', 'control-plane base URL (site.base_url) — required only when onboarding this machine')
491
+ .option('--project <name>', 'GAIA project name — required only to scaffold the committed repo config; omit for machine-only onboarding')
492
+ .option('--secret-env <VAR>', 'env var name to read the oauth client secret from (else TTY prompt)')
493
+ .option('--client-id <id>', 'oauth consumer id', 'gaia-agent')
494
+ .option('--machine-id <id>', 'machine host token for the context (defaults to hostname())')
495
+ .option('--user-id <kuerzel>', 'developer Kürzel for the user-global context')
496
+ .option('--machine-path <path>', 'user-global machine context path (defaults to ~/.config/conductor/conductor.config.machine.js)')
497
+ .option('--config <path>', 'target committed config path', './.gaia/conductor.config.js')
498
+ .option('--force', 'overwrite an existing committed config', false)
499
+ .option('--reonboard', 'force machine-context onboarding even if a context file exists', false)
500
+ .action(async (opts) => {
501
+ // Two independent axes decide what init writes:
502
+ // - machine axis: an existing context means project-only; --reonboard
503
+ // (or an absent context) forces machine-context (re)scaffolding.
504
+ // - repo axis: --project scaffolds the committed repo config; omitting
505
+ // it means machine-only (no repo). The 4 quadrants:
506
+ // context absent + project → both files
507
+ // context absent + no proj → machine-only onboarding
508
+ // context present + project → project-only
509
+ // context present + no proj → no-op (machine already onboarded)
510
+ const machinePath = opts.machinePath ?? machineContextPath();
511
+ const existing = await readMachineContext(machinePath);
512
+ const contextPresent = existsSync(machinePath);
513
+ const onboarding = !contextPresent || opts.reonboard;
514
+ const project = opts.project ?? '';
515
+ const hasProject = project.trim() !== '';
516
+ // Context present and nothing repo-scoped to do → no-op with guidance.
517
+ if (!onboarding && !hasProject) {
518
+ console.log(`machine already onboarded (${machinePath}) — pass --project to set up a repo`);
519
+ return;
520
+ }
521
+ let userId = opts.userId ?? '';
522
+ let secret = '';
523
+ const baseUrl = opts.baseUrl ?? '';
524
+ if (onboarding) {
525
+ // Full onboarding: base-url + user-id + secret are required here.
526
+ if (baseUrl.trim() === '') {
527
+ throw new Error('--base-url is required when onboarding this machine (no machine context yet, or --reonboard)');
528
+ }
529
+ if (userId.trim() === '') {
530
+ if (process.stdin.isTTY)
531
+ userId = await promptUserId();
532
+ if (userId.trim() === '') {
533
+ throw new Error('--user-id is required (or run in a TTY to be prompted)');
534
+ }
535
+ }
536
+ // The secret lives in the machine context; resolve it only when that
537
+ // context does not already carry one (existing values are never
538
+ // overwritten). Source: --secret-env value, else a TTY prompt.
539
+ if (typeof existing.client_secret !== 'string' ||
540
+ existing.client_secret.trim() === '') {
541
+ secret =
542
+ opts.secretEnv !== undefined
543
+ ? (process.env[opts.secretEnv] ?? '')
544
+ : '';
545
+ if (secret.trim() === '') {
546
+ if (process.stdin.isTTY)
547
+ secret = await promptSecret();
548
+ if (secret.trim() === '') {
549
+ throw new Error('OAuth client secret required: pass --secret-env <VAR> (exported) or run in a TTY to be prompted');
550
+ }
551
+ }
552
+ }
553
+ }
554
+ const inputs = {
555
+ baseUrl,
556
+ project,
557
+ clientId: opts.clientId,
558
+ secret,
559
+ userId,
560
+ ...(opts.machineId !== undefined
561
+ ? { machineId: opts.machineId }
562
+ : {}),
563
+ };
564
+ const res = await scaffold(inputs, {
565
+ configPath: opts.config,
566
+ force: opts.force,
567
+ machinePath,
568
+ skipMachine: !onboarding,
569
+ skipCommitted: !hasProject,
570
+ });
571
+ // Mode banner.
572
+ if (!onboarding) {
573
+ console.log(`machine context found (${machinePath}) → setting up project only`);
574
+ if (typeof existing.base_url !== 'string' ||
575
+ existing.base_url.trim() === '') {
576
+ console.log(`warning: ${machinePath} exists but looks incomplete (no base_url) — ` +
577
+ `run with --reonboard to fill the machine context`);
578
+ }
579
+ }
580
+ else if (!hasProject) {
581
+ console.log(`onboarding this machine (no project) → run again with --project inside a repo to set it up`);
582
+ }
583
+ else {
584
+ console.log(opts.reonboard
585
+ ? `re-onboarding this machine (--reonboard)`
586
+ : `no machine context → onboarding this machine`);
587
+ }
588
+ if (hasProject) {
589
+ console.log(res.wroteCommitted
590
+ ? `wrote ${res.committedPath}`
591
+ : `kept ${res.committedPath} (exists — pass --force to replace)`);
592
+ }
593
+ const m = res.machine;
594
+ if (onboarding) {
595
+ console.log(m.created
596
+ ? `wrote ${m.path} (user-global machine context)`
597
+ : m.filledKeys.length > 0
598
+ ? `updated ${m.path} (filled: ${m.filledKeys.join(', ')})`
599
+ : `kept ${m.path} (already complete)`);
600
+ }
601
+ console.log(`\nNext steps:\n` +
602
+ ` gaia dropsh auth login --provider session\n` +
603
+ ` gaia dropsh auth login --provider pm\n` +
604
+ ` gaia dropsh auth status # both profiles present`);
605
+ });
606
+ return program;
607
+ }
608
+ /**
609
+ * Attach the inherited dropsh CLI as a `gaia dropsh ...` subcommand.
610
+ *
611
+ * When a config is loadable, mounts the dropsh program built with the
612
+ * conductor's plugins (auth providers, etc.). Best-effort: if no config is
613
+ * loadable, the passthrough is simply not mounted.
614
+ *
615
+ * dropsh's own commands (e.g. `auth login`) reload their config from
616
+ * `--config` / `$DROPSH_CONFIG` / the `dropsh.config.js` default — they do not
617
+ * see the conductor config we already loaded. Default `$DROPSH_CONFIG` to the
618
+ * conductor's own config path so `gaia dropsh auth login` uses the same site +
619
+ * plugins without an explicit `--config` flag. An existing `$DROPSH_CONFIG`
620
+ * (or a `--config` flag) still wins.
621
+ */
622
+ /** Actionable hint shown when a `gaia dropsh` command runs with no loadable config. */
623
+ function dropshConfigHint(resolvedPath) {
624
+ return (`gaia dropsh: no conductor config could be loaded (looked at ${resolvedPath}).\n` +
625
+ `Point at one with --config <path>, set $DROPSH_CONFIG, or run from a directory ` +
626
+ `containing .gaia/conductor.config.js.`);
627
+ }
628
+ async function attachDropsh(program, deps) {
629
+ const config = deps.config ?? (await tryConfig(deps));
630
+ if (config && !process.env.DROPSH_CONFIG) {
631
+ process.env.DROPSH_CONFIG = config.config_path;
632
+ }
633
+ const dropsh = buildDropshProgram({
634
+ plugins: config?.plugins ?? [],
635
+ });
636
+ if (!config) {
637
+ // Registration is unconditional; a config problem must surface WHEN a dropsh
638
+ // command runs, not make the command vanish. The hook fires only on a real
639
+ // subcommand dispatch — never for `--help` — so discoverability holds.
640
+ dropsh.hook('preSubcommand', async (thisCommand) => {
641
+ const override = thisCommand.opts().config ??
642
+ process.env.DROPSH_CONFIG;
643
+ const resolved = defaultConfigPath(override);
644
+ let ok = false;
645
+ try {
646
+ await loadConductorConfig(resolved);
647
+ ok = true;
648
+ }
649
+ catch {
650
+ ok = false;
651
+ }
652
+ if (!ok) {
653
+ throw new Error(dropshConfigHint(resolved));
654
+ }
655
+ if (!process.env.DROPSH_CONFIG) {
656
+ process.env.DROPSH_CONFIG = resolved;
657
+ }
658
+ });
659
+ }
660
+ program.addCommand(dropsh);
661
+ }
662
+ export async function runGaiaCli(argv, deps = {}) {
663
+ const program = buildProgram(deps);
664
+ await attachDropsh(program, deps);
665
+ try {
666
+ await program.parseAsync(argv, { from: 'user' });
667
+ }
668
+ catch (err) {
669
+ // The mounted dropsh program calls exitOverride(), so commander surfaces
670
+ // help/errors as a thrown CommanderError instead of exiting the process.
671
+ // `--help`/`--version` are clean exits; commander-generated errors have
672
+ // already written their message to stderr, so only report our own thrown
673
+ // errors (e.g. the dropsh missing-config hint) here.
674
+ const e = err;
675
+ if (e.code === 'commander.helpDisplayed' ||
676
+ e.code === 'commander.version') {
677
+ return;
678
+ }
679
+ if (typeof e.code === 'string' && e.code.startsWith('commander.')) {
680
+ process.exitCode = process.exitCode ?? 1;
681
+ return;
682
+ }
683
+ process.stderr.write(`${e.message ?? String(err)}\n`);
684
+ process.exitCode = process.exitCode ?? 1;
685
+ }
686
+ }
687
+ export async function main(argv) {
688
+ // Drop node + script path; commander parses the rest as user args.
689
+ await runGaiaCli(argv.slice(2));
690
+ }