@gaia-ai/gaia 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/bin/gaia +1 -1
  3. package/dist/src/cli/gaia.d.ts +24 -0
  4. package/dist/src/cli/gaia.js +572 -0
  5. package/dist/src/cli/init.d.ts +74 -0
  6. package/dist/src/cli/init.js +220 -0
  7. package/dist/src/cli/local-registry.d.ts +14 -0
  8. package/dist/src/cli/local-registry.js +56 -0
  9. package/dist/src/config.d.ts +13 -0
  10. package/dist/src/config.js +186 -0
  11. package/dist/src/core/conductor-id.d.ts +1 -0
  12. package/dist/src/core/conductor-id.js +8 -0
  13. package/dist/src/core/conductor.d.ts +47 -0
  14. package/dist/src/core/conductor.js +287 -0
  15. package/dist/src/core/exec.d.ts +33 -0
  16. package/dist/src/core/exec.js +65 -0
  17. package/dist/src/core/logger.d.ts +31 -0
  18. package/dist/src/core/logger.js +36 -0
  19. package/dist/src/core/slug.d.ts +9 -0
  20. package/dist/src/core/slug.js +18 -0
  21. package/dist/src/index.d.ts +16 -0
  22. package/dist/src/index.js +9 -0
  23. package/dist/src/plugin-api.d.ts +7 -0
  24. package/dist/src/plugin-api.js +3 -0
  25. package/dist/src/plugins/agent/agent.d.ts +20 -0
  26. package/dist/src/plugins/agent/agent.js +1 -0
  27. package/dist/src/plugins/auth/basic.d.ts +11 -0
  28. package/dist/src/plugins/auth/basic.js +35 -0
  29. package/dist/src/plugins/executor/executor.d.ts +61 -0
  30. package/dist/src/plugins/executor/executor.js +1 -0
  31. package/dist/src/plugins/plugins.d.ts +38 -0
  32. package/dist/src/plugins/plugins.js +16 -0
  33. package/dist/src/plugins/registry-exports.d.ts +6 -0
  34. package/dist/src/plugins/registry-exports.js +6 -0
  35. package/dist/src/plugins/remote/drupal.d.ts +47 -0
  36. package/dist/src/plugins/remote/drupal.js +337 -0
  37. package/dist/src/plugins/remote/fake.d.ts +81 -0
  38. package/dist/src/plugins/remote/fake.js +203 -0
  39. package/dist/src/plugins/remote/remote.d.ts +143 -0
  40. package/dist/src/plugins/remote/remote.js +1 -0
  41. package/dist/src/plugins/workspace/fake.d.ts +9 -0
  42. package/dist/src/plugins/workspace/fake.js +19 -0
  43. package/dist/src/plugins/workspace/git.d.ts +53 -0
  44. package/dist/src/plugins/workspace/git.js +113 -0
  45. package/dist/src/plugins/workspace/instructions.d.ts +6 -0
  46. package/dist/src/plugins/workspace/instructions.js +16 -0
  47. package/dist/src/plugins/workspace/workspace.d.ts +33 -0
  48. package/dist/src/plugins/workspace/workspace.js +1 -0
  49. package/dist/src/types.d.ts +51 -0
  50. package/dist/src/types.js +1 -0
  51. package/package.json +12 -8
  52. package/dist/index.js +0 -2076
  53. package/dist/plugin.js +0 -78
  54. package/dist/plugins.js +0 -1557
package/README.md CHANGED
@@ -54,8 +54,8 @@ tag. Requirements:
54
54
  To reproduce the published artifact locally:
55
55
 
56
56
  ```bash
57
- pnpm run prepare:publish # bundle + stage conductor/publish/
58
- pnpm run verify:package # pack the staged package, install it in a temp dir, smoke-test
57
+ pnpm run prepare:publish # stage conductor/publish/ + each plugins/*/publish/
58
+ pnpm run verify:package # pack the staged set, install it in a temp dir, smoke-test
59
59
  ```
60
60
 
61
61
  ## License
package/bin/gaia CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import('../dist/index.js').then((m) => m.main(process.argv));
2
+ import('../dist/src/index.js').then((m) => m.main(process.argv));
@@ -0,0 +1,24 @@
1
+ import { type ConductorLogger } from '../core/logger.js';
2
+ import type { GaiaAgent } from '../plugins/agent/agent.js';
3
+ import type { GaiaExecutor } from '../plugins/executor/executor.js';
4
+ import type { GaiaRemote } from '../plugins/remote/remote.js';
5
+ import type { GaiaWorkspace } from '../plugins/workspace/workspace.js';
6
+ import type { ConductorFileConfig } from '../types.js';
7
+ /** Test seam: inject any subset of dependencies. */
8
+ export interface GaiaCliDeps {
9
+ remote?: GaiaRemote;
10
+ executor?: GaiaExecutor;
11
+ workspace?: GaiaWorkspace;
12
+ agent?: GaiaAgent;
13
+ config?: ConductorFileConfig;
14
+ }
15
+ export declare function parseHerdrJson(output: string, command: string): unknown;
16
+ /**
17
+ * Start-time auth gate. Returns true if authenticated (session or session-less
18
+ * provider); otherwise logs a single clear line and returns false. Must run
19
+ * before remote resolution (resolveRemote), which calls resolveAuth and throws
20
+ * when unauthenticated.
21
+ */
22
+ export declare function ensureAuthenticated(config: ConductorFileConfig, logger: ConductorLogger): Promise<boolean>;
23
+ export declare function runGaiaCli(argv: string[], deps?: GaiaCliDeps): Promise<void>;
24
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,572 @@
1
+ import { dirname } from 'node:path';
2
+ import { createInterface } from 'node:readline';
3
+ import { Command } from 'commander';
4
+ import { authStatus, buildProgram as buildDropshProgram } from 'dropsh';
5
+ import { loadConductorConfig } from '../config.js';
6
+ import { Conductor } from '../core/conductor.js';
7
+ import { conductorId } from '../core/conductor-id.js';
8
+ import { CommandRunner, exec, setDefaultCommandRunner } from '../core/exec.js';
9
+ import { createLogger } from '../core/logger.js';
10
+ import { selectAgent, selectExecutor, selectRemote, selectWorkspace, } from '../plugins/plugins.js';
11
+ import { machineContextPath, readMachineContext, scaffold, } from './init.js';
12
+ import * as registry from './local-registry.js';
13
+ /** Default config path; honors $GAIA_CONDUCTOR_CONFIG, else ./.gaia/conductor.config.js. */
14
+ function defaultConfigPath(override) {
15
+ return (override ??
16
+ process.env.GAIA_CONDUCTOR_CONFIG ??
17
+ './.gaia/conductor.config.js');
18
+ }
19
+ async function resolveConfig(deps, configPathOverride) {
20
+ if (deps.config) {
21
+ return deps.config;
22
+ }
23
+ return loadConductorConfig(defaultConfigPath(configPathOverride));
24
+ }
25
+ async function resolveRemote(deps, config) {
26
+ return deps.remote ?? (await selectRemote(config));
27
+ }
28
+ function checkoutRootOf(config) {
29
+ return dirname(config.config_path);
30
+ }
31
+ /**
32
+ * The conductor's stable identity (gaia_conductor.machine_id). The loader
33
+ * always resolves this (config override or hostname+path hash), so lifecycle
34
+ * commands read it here instead of re-deriving the hash — otherwise a pinned
35
+ * machine_id and the CLI's id would diverge.
36
+ */
37
+ function conductorIdOf(config) {
38
+ return config.machine_id ?? conductorId(checkoutRootOf(config));
39
+ }
40
+ /** Read the `conductor`-level --log-level / --log-sink flags from a subcommand. */
41
+ function logOptsOf(cmd) {
42
+ const opts = cmd.optsWithGlobals();
43
+ return {
44
+ ...(opts.logLevel !== undefined ? { level: opts.logLevel } : {}),
45
+ ...(opts.logSink !== undefined ? { sink: opts.logSink } : {}),
46
+ };
47
+ }
48
+ /** Create the conductor logger (with CLI log overrides) and route all exec logging through it. */
49
+ function loggerFor(checkoutRoot, log = {}) {
50
+ const logger = createLogger({ checkoutRoot, ...log });
51
+ setDefaultCommandRunner(new CommandRunner(logger));
52
+ return logger;
53
+ }
54
+ // --- herdr host (untested: shells out to herdr) ---------------------------
55
+ export function parseHerdrJson(output, command) {
56
+ try {
57
+ return JSON.parse(output);
58
+ }
59
+ catch {
60
+ throw new Error(`herdr ${command} returned invalid JSON`);
61
+ }
62
+ }
63
+ /** Spawn a detached herdr pane running the given command in cwd. */
64
+ async function spawnViaHerdr(label, cwd, cmd) {
65
+ const created = await exec('herdr', [
66
+ 'workspace',
67
+ 'create',
68
+ '--cwd',
69
+ cwd,
70
+ '--label',
71
+ label,
72
+ '--no-focus',
73
+ ]);
74
+ const parsed = parseHerdrJson(created, 'workspace create');
75
+ const paneId = parsed.result?.root_pane?.pane_id;
76
+ if (!paneId) {
77
+ throw new Error('herdr workspace create returned no pane id');
78
+ }
79
+ await exec('herdr', ['pane', 'run', paneId, cmd]);
80
+ }
81
+ /** Hard-kill a herdr-hosted conductor by its pane label. */
82
+ async function killViaHerdr(label) {
83
+ const listed = await exec('herdr', ['workspace', 'list']);
84
+ const parsed = parseHerdrJson(listed, 'workspace list');
85
+ const ws = (parsed.result?.workspaces ?? []).find((w) => w.label === label);
86
+ if (ws?.workspace_id) {
87
+ await exec('herdr', ['workspace', 'close', ws.workspace_id]);
88
+ }
89
+ }
90
+ // --- ls/status freshness ----------------------------------------------------
91
+ const DEFAULT_FRESH_S = 120;
92
+ function freshnessThresholdS(config) {
93
+ return config
94
+ ? Math.max(DEFAULT_FRESH_S, config.lease_seconds * 2)
95
+ : DEFAULT_FRESH_S;
96
+ }
97
+ function classify(hub, freshS) {
98
+ // No host probe here (host calls are untested) → can't tell host-missing
99
+ // from wedged. registry+no-hub = registry-only; stale-hub = wedged.
100
+ if (!hub) {
101
+ return 'registry-only';
102
+ }
103
+ if (hub.status === 'offline') {
104
+ return 'stopped';
105
+ }
106
+ const nowS = Math.floor(Date.now() / 1000);
107
+ const fresh = hub.lastSeen > 0 && nowS - hub.lastSeen <= freshS;
108
+ return fresh ? 'running' : 'wedged';
109
+ }
110
+ async function buildLsRows(remote, config, onlyId) {
111
+ const entries = await registry.list();
112
+ let hub = [];
113
+ try {
114
+ hub = await remote.listConductors('me');
115
+ }
116
+ catch {
117
+ hub = [];
118
+ }
119
+ const hubById = new Map(hub.map((c) => [c.id, c]));
120
+ const freshS = freshnessThresholdS(config);
121
+ const ids = new Set();
122
+ for (const e of entries) {
123
+ ids.add(e.id);
124
+ }
125
+ for (const c of hub) {
126
+ ids.add(c.id);
127
+ }
128
+ const rows = [];
129
+ for (const id of ids) {
130
+ if (onlyId && id !== onlyId) {
131
+ continue;
132
+ }
133
+ const entry = entries.find((e) => e.id === id);
134
+ const h = hubById.get(id);
135
+ rows.push({
136
+ id,
137
+ project: entry?.project ?? h?.project ?? '',
138
+ label: entry?.label ?? h?.label ?? '',
139
+ host: entry?.host ?? '-',
140
+ status: classify(h, freshS),
141
+ });
142
+ }
143
+ return rows;
144
+ }
145
+ function printRows(rows) {
146
+ if (rows.length === 0) {
147
+ console.log('no conductors registered');
148
+ return;
149
+ }
150
+ for (const r of rows) {
151
+ console.log(`${r.id}\t${r.status}\t${r.host}\t${r.project}\t${r.label}`);
152
+ }
153
+ }
154
+ // --- command handlers -------------------------------------------------------
155
+ /**
156
+ * Start-time auth gate. Returns true if authenticated (session or session-less
157
+ * provider); otherwise logs a single clear line and returns false. Must run
158
+ * before remote resolution (resolveRemote), which calls resolveAuth and throws
159
+ * when unauthenticated.
160
+ */
161
+ export async function ensureAuthenticated(config, logger) {
162
+ const st = await authStatus({
163
+ baseUrl: config.site.base_url,
164
+ plugins: config.plugins ?? [],
165
+ });
166
+ if (!st.loggedIn) {
167
+ logger.error({ baseUrl: config.site.base_url }, `not logged in against ${config.site.base_url} — run 'gaia dropsh auth login'`);
168
+ return false;
169
+ }
170
+ return true;
171
+ }
172
+ async function cmdPoll(deps, log = {}) {
173
+ const config = await resolveConfig(deps);
174
+ const checkoutRoot = checkoutRootOf(config);
175
+ const logger = loggerFor(checkoutRoot, log);
176
+ if (!(await ensureAuthenticated(config, logger)))
177
+ return;
178
+ const remote = await resolveRemote(deps, config);
179
+ const executor = deps.executor ?? (await selectExecutor(config));
180
+ const workspace = deps.workspace ?? (await selectWorkspace(config));
181
+ const agent = deps.agent ?? (await selectAgent(config));
182
+ const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
183
+ await conductor.start();
184
+ await conductor.tick();
185
+ }
186
+ async function cmdStartForeground(deps, log = {}) {
187
+ const config = await resolveConfig(deps);
188
+ const checkoutRoot = checkoutRootOf(config);
189
+ const logger = loggerFor(checkoutRoot, log);
190
+ if (!(await ensureAuthenticated(config, logger)))
191
+ return;
192
+ const remote = await resolveRemote(deps, config);
193
+ const executor = deps.executor ?? (await selectExecutor(config));
194
+ const workspace = deps.workspace ?? (await selectWorkspace(config));
195
+ const agent = deps.agent ?? (await selectAgent(config));
196
+ const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
197
+ await conductor.start();
198
+ const controller = new AbortController();
199
+ const onSignal = () => controller.abort();
200
+ process.once('SIGINT', onSignal);
201
+ process.once('SIGTERM', onSignal);
202
+ try {
203
+ await conductor.serve(controller.signal);
204
+ }
205
+ finally {
206
+ process.removeListener('SIGINT', onSignal);
207
+ process.removeListener('SIGTERM', onSignal);
208
+ }
209
+ }
210
+ async function cmdStart(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 id = conductorIdOf(config);
218
+ const existing = await registry.get(id);
219
+ if (existing) {
220
+ const hubStatus = await remote.getConductorStatus(id);
221
+ if (hubStatus !== null && hubStatus !== 'offline') {
222
+ console.log(`conductor already running for ${config.project}`);
223
+ return;
224
+ }
225
+ }
226
+ const handle = `gaia-conductor:${id}`;
227
+ await registry.register({
228
+ id,
229
+ path: checkoutRoot,
230
+ project: config.project,
231
+ label: config.label,
232
+ host: 'herdr',
233
+ handle,
234
+ });
235
+ // The detached foreground process is a fresh CLI invocation — forward the
236
+ // log flags so the herdr-hosted loop logs at the requested level. Sink stays
237
+ // forced to file (herdr-hosted = no TTY) unless the caller overrode it.
238
+ const fgFlags = [
239
+ log.level ? `--log-level ${log.level}` : '',
240
+ log.sink ? `--log-sink ${log.sink}` : '',
241
+ ]
242
+ .filter(Boolean)
243
+ .join(' ');
244
+ const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground`.replace(/\s+/g, ' ');
245
+ try {
246
+ await spawnViaHerdr(handle, checkoutRoot, fgCmd);
247
+ logger.info({ id, handle }, 'started conductor via herdr');
248
+ }
249
+ catch (err) {
250
+ logger.error({ id, err: err.message }, 'could not start via herdr');
251
+ logger.warn({}, 'Hint: run `gaia conductor start --foreground` under a service manager (systemd-user / docker) on hosts without herdr.');
252
+ }
253
+ }
254
+ async function cmdStop(deps, now) {
255
+ const config = await resolveConfig(deps);
256
+ const remote = await resolveRemote(deps, config);
257
+ const id = conductorIdOf(config);
258
+ if (now) {
259
+ const entry = await registry.get(id);
260
+ if (entry && entry.host === 'herdr') {
261
+ await killViaHerdr(entry.handle);
262
+ console.log(`hard-stopped conductor ${id} (${entry.handle})`);
263
+ }
264
+ else {
265
+ console.log(`no herdr-hosted conductor to hard-stop for ${id}`);
266
+ }
267
+ // A hard kill just stops the heartbeat; it never deletes the entity. The
268
+ // Drupal cron reaper flips the now-stale registration to offline (single
269
+ // authority for the offline transition — see gaia_core cron).
270
+ return;
271
+ }
272
+ // Graceful stop: there is no drain phase — the conductor goes offline at once,
273
+ // just like a hard kill, but writes offline itself instead of waiting for the
274
+ // cron reaper. Stop the process first (else its next heartbeat would flip it
275
+ // back online), then mark it offline. In-flight runs are not awaited.
276
+ const entry = await registry.get(id);
277
+ if (entry && entry.host === 'herdr') {
278
+ await killViaHerdr(entry.handle);
279
+ }
280
+ await remote.setConductorStatus(id, 'offline');
281
+ console.log(`stopped conductor ${id} (offline)`);
282
+ }
283
+ async function cmdLs(deps) {
284
+ const config = deps.config ?? (await tryConfig(deps));
285
+ const remote = await resolveRemote(deps, config ?? (await resolveConfig(deps)));
286
+ const rows = await buildLsRows(remote, config);
287
+ printRows(rows);
288
+ }
289
+ async function cmdStatus(deps) {
290
+ const config = await resolveConfig(deps);
291
+ const remote = await resolveRemote(deps, config);
292
+ const id = conductorIdOf(config);
293
+ const rows = await buildLsRows(remote, config, id);
294
+ printRows(rows);
295
+ }
296
+ async function cmdRm(deps) {
297
+ const config = await resolveConfig(deps);
298
+ const id = conductorIdOf(config);
299
+ await registry.remove(id);
300
+ console.log(`removed conductor ${id} from registry`);
301
+ }
302
+ /** ls may run without a config file; best-effort load. */
303
+ async function tryConfig(deps) {
304
+ if (deps.config) {
305
+ return deps.config;
306
+ }
307
+ try {
308
+ return await loadConductorConfig(defaultConfigPath());
309
+ }
310
+ catch {
311
+ return undefined;
312
+ }
313
+ }
314
+ /** Prompt for the developer Kürzel / user id on an interactive terminal. */
315
+ async function promptUserId() {
316
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
317
+ try {
318
+ const answer = await new Promise((resolve) => rl.question('Your Kürzel / user id: ', resolve));
319
+ return answer.trim();
320
+ }
321
+ finally {
322
+ rl.close();
323
+ }
324
+ }
325
+ /** Prompt for the OAuth client secret without echoing the typed characters. */
326
+ async function promptSecret() {
327
+ const rl = createInterface({
328
+ input: process.stdin,
329
+ output: process.stdout,
330
+ terminal: true,
331
+ });
332
+ // Mute character echo while the secret is typed.
333
+ rl._writeToOutput = (s) => {
334
+ if (!rl.muted || s.includes('\n'))
335
+ process.stdout.write(s);
336
+ };
337
+ try {
338
+ const answer = await new Promise((resolve) => {
339
+ rl.question('OAuth client secret: ', (a) => {
340
+ process.stdout.write('\n');
341
+ resolve(a);
342
+ });
343
+ rl.muted = true;
344
+ });
345
+ return answer.trim();
346
+ }
347
+ finally {
348
+ rl.close();
349
+ }
350
+ }
351
+ // --- program ----------------------------------------------------------------
352
+ function buildProgram(deps) {
353
+ const program = new Command();
354
+ program.name('gaia').description('GAIA conductor + client CLI');
355
+ const conductor = program
356
+ .command('conductor')
357
+ .description('node-agent lifecycle + local registry')
358
+ .option('--log-level <level>', 'log verbosity: debug | info | warn | error (overrides GAIA_LOG_LEVEL)')
359
+ .option('--log-sink <sink>', 'log sink: stdout | file (overrides GAIA_CONDUCTOR_LOG)')
360
+ .addHelpText('after', `
361
+ Logging (precedence: CLI flag > env var > default):
362
+ --log-level <level> debug | info (default) | warn | error.
363
+ Set debug to see per-poll ticks, claims, and idle cycles.
364
+ --log-sink <sink> stdout | file (writes <checkoutRoot>/log.txt).
365
+ Default: stdout on a TTY, file otherwise.
366
+ GAIA_LOG_LEVEL env fallback for --log-level.
367
+ GAIA_CONDUCTOR_LOG env fallback for --log-sink.
368
+
369
+ Examples:
370
+ gaia conductor --log-level debug start --foreground
371
+ gaia conductor --log-level debug poll
372
+ GAIA_LOG_LEVEL=debug gaia conductor poll`);
373
+ conductor
374
+ .command('start')
375
+ .description('start the conductor loop (herdr-hosted by default)')
376
+ .option('--foreground', 'run the loop in this process', false)
377
+ .action(async function (opts) {
378
+ if (opts.foreground) {
379
+ await cmdStartForeground(deps, logOptsOf(this));
380
+ }
381
+ else {
382
+ await cmdStart(deps, logOptsOf(this));
383
+ }
384
+ });
385
+ conductor
386
+ .command('poll')
387
+ .description('run one conductor cycle then exit')
388
+ .action(async function () {
389
+ await cmdPoll(deps, logOptsOf(this));
390
+ });
391
+ conductor
392
+ .command('stop')
393
+ .description('stop the conductor: graceful offline (default) or hard kill (--now)')
394
+ .option('--now', 'hard-kill via host and let the cron reaper mark it offline', false)
395
+ .action(async (opts) => {
396
+ await cmdStop(deps, opts.now);
397
+ });
398
+ conductor
399
+ .command('ls')
400
+ .description('list conductors on this machine + status')
401
+ .action(async () => {
402
+ await cmdLs(deps);
403
+ });
404
+ conductor
405
+ .command('status')
406
+ .description('status of the conductor for this checkout')
407
+ .action(async () => {
408
+ await cmdStatus(deps);
409
+ });
410
+ conductor
411
+ .command('rm')
412
+ .description('deregister the conductor for this checkout')
413
+ .action(async () => {
414
+ await cmdRm(deps);
415
+ });
416
+ conductor
417
+ .command('init')
418
+ .description('scaffold the committed .gaia/conductor.config.js for this repo plus the user-global conductor.config.machine.js context (identity + connection incl. secret)')
419
+ .requiredOption('--base-url <url>', 'control-plane base URL (site.base_url)')
420
+ .requiredOption('--project <name>', 'GAIA project name')
421
+ .option('--secret-env <VAR>', 'env var name to read the oauth client secret from (else TTY prompt)')
422
+ .option('--client-id <id>', 'oauth consumer id', 'gaia-agent')
423
+ .option('--machine-id <id>', 'machine host token for the context (defaults to hostname())')
424
+ .option('--user-id <kuerzel>', 'developer Kürzel for the user-global context')
425
+ .option('--machine-path <path>', 'user-global machine context path (defaults to ~/.config/conductor/conductor.config.machine.js)')
426
+ .option('--config <path>', 'target committed config path', './.gaia/conductor.config.js')
427
+ .option('--force', 'overwrite an existing committed config', false)
428
+ .action(async (opts) => {
429
+ let userId = opts.userId;
430
+ if (userId === undefined || userId.trim() === '') {
431
+ if (process.stdin.isTTY) {
432
+ userId = await promptUserId();
433
+ }
434
+ if (userId === undefined || userId.trim() === '') {
435
+ throw new Error('--user-id is required (or run in a TTY to be prompted)');
436
+ }
437
+ }
438
+ // The secret lives in the machine context; resolve it only when that
439
+ // context does not already carry one (existing values are never
440
+ // overwritten). Source: --secret-env value, else a TTY prompt.
441
+ const machinePath = opts.machinePath ?? machineContextPath();
442
+ const existing = await readMachineContext(machinePath);
443
+ let secret = '';
444
+ if (typeof existing.client_secret !== 'string' ||
445
+ existing.client_secret.trim() === '') {
446
+ secret =
447
+ opts.secretEnv !== undefined
448
+ ? (process.env[opts.secretEnv] ?? '')
449
+ : '';
450
+ if (secret.trim() === '') {
451
+ if (process.stdin.isTTY) {
452
+ secret = await promptSecret();
453
+ }
454
+ if (secret.trim() === '') {
455
+ throw new Error('OAuth client secret required: pass --secret-env <VAR> (exported) or run in a TTY to be prompted');
456
+ }
457
+ }
458
+ }
459
+ const inputs = {
460
+ baseUrl: opts.baseUrl,
461
+ project: opts.project,
462
+ clientId: opts.clientId,
463
+ secret,
464
+ userId,
465
+ ...(opts.machineId !== undefined
466
+ ? { machineId: opts.machineId }
467
+ : {}),
468
+ };
469
+ const res = await scaffold(inputs, {
470
+ configPath: opts.config,
471
+ force: opts.force,
472
+ machinePath,
473
+ });
474
+ console.log(res.wroteCommitted
475
+ ? `wrote ${res.committedPath}`
476
+ : `kept ${res.committedPath} (exists — pass --force to replace)`);
477
+ const m = res.machine;
478
+ console.log(m.created
479
+ ? `wrote ${m.path} (user-global machine context)`
480
+ : m.filledKeys.length > 0
481
+ ? `updated ${m.path} (filled: ${m.filledKeys.join(', ')})`
482
+ : `kept ${m.path} (already complete)`);
483
+ console.log(`\nNext steps:\n` +
484
+ ` gaia dropsh auth login --provider session\n` +
485
+ ` gaia dropsh auth login --provider pm\n` +
486
+ ` gaia dropsh auth status # both profiles present`);
487
+ });
488
+ return program;
489
+ }
490
+ /**
491
+ * Attach the inherited dropsh CLI as a `gaia dropsh ...` subcommand.
492
+ *
493
+ * When a config is loadable, mounts the dropsh program built with the
494
+ * conductor's plugins (auth providers, etc.). Best-effort: if no config is
495
+ * loadable, the passthrough is simply not mounted.
496
+ *
497
+ * dropsh's own commands (e.g. `auth login`) reload their config from
498
+ * `--config` / `$DROPSH_CONFIG` / the `dropsh.config.js` default — they do not
499
+ * see the conductor config we already loaded. Default `$DROPSH_CONFIG` to the
500
+ * conductor's own config path so `gaia dropsh auth login` uses the same site +
501
+ * plugins without an explicit `--config` flag. An existing `$DROPSH_CONFIG`
502
+ * (or a `--config` flag) still wins.
503
+ */
504
+ /** Actionable hint shown when a `gaia dropsh` command runs with no loadable config. */
505
+ function dropshConfigHint(resolvedPath) {
506
+ return (`gaia dropsh: no conductor config could be loaded (looked at ${resolvedPath}).\n` +
507
+ `Point at one with --config <path>, set $DROPSH_CONFIG, or run from a directory ` +
508
+ `containing .gaia/conductor.config.js.`);
509
+ }
510
+ async function attachDropsh(program, deps) {
511
+ const config = deps.config ?? (await tryConfig(deps));
512
+ if (config && !process.env.DROPSH_CONFIG) {
513
+ process.env.DROPSH_CONFIG = config.config_path;
514
+ }
515
+ const dropsh = buildDropshProgram({
516
+ plugins: config?.plugins ?? [],
517
+ });
518
+ if (!config) {
519
+ // Registration is unconditional; a config problem must surface WHEN a dropsh
520
+ // command runs, not make the command vanish. The hook fires only on a real
521
+ // subcommand dispatch — never for `--help` — so discoverability holds.
522
+ dropsh.hook('preSubcommand', async (thisCommand) => {
523
+ const override = thisCommand.opts().config ??
524
+ process.env.DROPSH_CONFIG;
525
+ const resolved = defaultConfigPath(override);
526
+ let ok = false;
527
+ try {
528
+ await loadConductorConfig(resolved);
529
+ ok = true;
530
+ }
531
+ catch {
532
+ ok = false;
533
+ }
534
+ if (!ok) {
535
+ throw new Error(dropshConfigHint(resolved));
536
+ }
537
+ if (!process.env.DROPSH_CONFIG) {
538
+ process.env.DROPSH_CONFIG = resolved;
539
+ }
540
+ });
541
+ }
542
+ program.addCommand(dropsh);
543
+ }
544
+ export async function runGaiaCli(argv, deps = {}) {
545
+ const program = buildProgram(deps);
546
+ await attachDropsh(program, deps);
547
+ try {
548
+ await program.parseAsync(argv, { from: 'user' });
549
+ }
550
+ catch (err) {
551
+ // The mounted dropsh program calls exitOverride(), so commander surfaces
552
+ // help/errors as a thrown CommanderError instead of exiting the process.
553
+ // `--help`/`--version` are clean exits; commander-generated errors have
554
+ // already written their message to stderr, so only report our own thrown
555
+ // errors (e.g. the dropsh missing-config hint) here.
556
+ const e = err;
557
+ if (e.code === 'commander.helpDisplayed' ||
558
+ e.code === 'commander.version') {
559
+ return;
560
+ }
561
+ if (typeof e.code === 'string' && e.code.startsWith('commander.')) {
562
+ process.exitCode = process.exitCode ?? 1;
563
+ return;
564
+ }
565
+ process.stderr.write(`${e.message ?? String(err)}\n`);
566
+ process.exitCode = process.exitCode ?? 1;
567
+ }
568
+ }
569
+ export async function main(argv) {
570
+ // Drop node + script path; commander parses the rest as user args.
571
+ await runGaiaCli(argv.slice(2));
572
+ }
@@ -0,0 +1,74 @@
1
+ export interface InitInputs {
2
+ baseUrl: string;
3
+ project: string;
4
+ clientId: string;
5
+ /** The resolved OAuth client secret value (stored in the machine context). */
6
+ secret: string;
7
+ machineId?: string;
8
+ userId?: string;
9
+ }
10
+ export interface ScaffoldOptions {
11
+ configPath: string;
12
+ force: boolean;
13
+ machinePath?: string;
14
+ }
15
+ export interface ScaffoldResult {
16
+ committedPath: string;
17
+ wroteCommitted: boolean;
18
+ machine: MachineContextResult;
19
+ }
20
+ /**
21
+ * The user-global machine context: a plain importable module carrying the
22
+ * developer's machine identity and connection (incl. the OAuth client secret).
23
+ * Committed configs import it to compose machine_id and read
24
+ * base_url / client_id / client_secret. Gitignored, user-only (chmod 0600).
25
+ */
26
+ export interface MachineContext {
27
+ machine_id: string;
28
+ user_id: string;
29
+ base_url: string;
30
+ client_id: string;
31
+ client_secret: string;
32
+ }
33
+ export interface MachineContextOptions {
34
+ path: string;
35
+ userId: string;
36
+ baseUrl: string;
37
+ clientId: string;
38
+ secret: string;
39
+ machineId?: string;
40
+ }
41
+ export interface MachineContextResult {
42
+ path: string;
43
+ created: boolean;
44
+ filledKeys: string[];
45
+ }
46
+ /**
47
+ * The committed, structural conductor config. `project` is the only per-repo
48
+ * value and is baked in here; connection + identity (incl. the secret) come from
49
+ * the user-global machine context (~/.config/conductor/conductor.config.machine.js),
50
+ * and machine_id is composed as `${user_id}-${machine_id}-${project}`. An
51
+ * optional, gitignored conductor.config.local.js beside this file may override
52
+ * any field — it is loaded if present but never created by `gaia conductor init`.
53
+ */
54
+ export declare function renderCommittedConfig(inputs: Pick<InitInputs, 'project'>): string;
55
+ /** The user-global machine context module: identity + connection (incl. secret). */
56
+ export declare function renderMachineContext(ctx: MachineContext): string;
57
+ /** The user-global machine context path: ~/.config/conductor/conductor.config.machine.js */
58
+ export declare function machineContextPath(): string;
59
+ /** Import an existing context module's default export, or {} if absent/broken. */
60
+ export declare function readMachineContext(path: string): Promise<Partial<MachineContext>>;
61
+ /**
62
+ * Create-if-missing / fill-only-missing the user-global machine context.
63
+ * Existing values always win; only absent/blank keys are filled. machine_id
64
+ * defaults to hostname(). A no-op (no rewrite) when the file is already complete.
65
+ * The file is written user-only (chmod 0600) since it holds the client secret.
66
+ */
67
+ export declare function scaffoldMachineContext(opts: MachineContextOptions): Promise<MachineContextResult>;
68
+ /**
69
+ * Scaffold the two conductor config files: the committed conductor.config.js
70
+ * (created only if missing — never overwritten unless `force`) and the
71
+ * user-global machine context (create-if-missing / fill-only-missing). The
72
+ * optional per-project conductor.config.local.js is NOT generated.
73
+ */
74
+ export declare function scaffold(inputs: InitInputs, opts: ScaffoldOptions): Promise<ScaffoldResult>;