@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.
- package/README.md +2 -2
- package/bin/gaia +1 -1
- package/dist/src/cli/gaia.d.ts +24 -0
- package/dist/src/cli/gaia.js +572 -0
- package/dist/src/cli/init.d.ts +74 -0
- package/dist/src/cli/init.js +220 -0
- package/dist/src/cli/local-registry.d.ts +14 -0
- package/dist/src/cli/local-registry.js +56 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/config.js +186 -0
- package/dist/src/core/conductor-id.d.ts +1 -0
- package/dist/src/core/conductor-id.js +8 -0
- package/dist/src/core/conductor.d.ts +47 -0
- package/dist/src/core/conductor.js +287 -0
- package/dist/src/core/exec.d.ts +33 -0
- package/dist/src/core/exec.js +65 -0
- package/dist/src/core/logger.d.ts +31 -0
- package/dist/src/core/logger.js +36 -0
- package/dist/src/core/slug.d.ts +9 -0
- package/dist/src/core/slug.js +18 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +9 -0
- package/dist/src/plugin-api.d.ts +7 -0
- package/dist/src/plugin-api.js +3 -0
- package/dist/src/plugins/agent/agent.d.ts +20 -0
- package/dist/src/plugins/agent/agent.js +1 -0
- package/dist/src/plugins/auth/basic.d.ts +11 -0
- package/dist/src/plugins/auth/basic.js +35 -0
- package/dist/src/plugins/executor/executor.d.ts +61 -0
- package/dist/src/plugins/executor/executor.js +1 -0
- package/dist/src/plugins/plugins.d.ts +38 -0
- package/dist/src/plugins/plugins.js +16 -0
- package/dist/src/plugins/registry-exports.d.ts +6 -0
- package/dist/src/plugins/registry-exports.js +6 -0
- package/dist/src/plugins/remote/drupal.d.ts +47 -0
- package/dist/src/plugins/remote/drupal.js +337 -0
- package/dist/src/plugins/remote/fake.d.ts +81 -0
- package/dist/src/plugins/remote/fake.js +203 -0
- package/dist/src/plugins/remote/remote.d.ts +143 -0
- package/dist/src/plugins/remote/remote.js +1 -0
- package/dist/src/plugins/workspace/fake.d.ts +9 -0
- package/dist/src/plugins/workspace/fake.js +19 -0
- package/dist/src/plugins/workspace/git.d.ts +53 -0
- package/dist/src/plugins/workspace/git.js +113 -0
- package/dist/src/plugins/workspace/instructions.d.ts +6 -0
- package/dist/src/plugins/workspace/instructions.js +16 -0
- package/dist/src/plugins/workspace/workspace.d.ts +33 -0
- package/dist/src/plugins/workspace/workspace.js +1 -0
- package/dist/src/types.d.ts +51 -0
- package/dist/src/types.js +1 -0
- package/package.json +12 -8
- package/dist/index.js +0 -2076
- package/dist/plugin.js +0 -78
- 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 #
|
|
58
|
-
pnpm run verify:package # pack the staged
|
|
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>;
|