@general-input/cli 0.1.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/LICENSE +59 -0
- package/README.md +39 -0
- package/dist/cli.js +2999 -0
- package/dist/cli.js.map +1 -0
- package/package.json +58 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2999 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/lib/output.ts
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
function printSuccess(message) {
|
|
9
|
+
process.stdout.write(`${chalk.green("\u2713")} ${message}
|
|
10
|
+
`);
|
|
11
|
+
}
|
|
12
|
+
function printInfo(message) {
|
|
13
|
+
process.stdout.write(`${chalk.dim("\xB7")} ${message}
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
function printWarning(message) {
|
|
17
|
+
process.stderr.write(`${chalk.yellow("!")} ${message}
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
function printError(message) {
|
|
21
|
+
process.stderr.write(`${chalk.red("\u2717")} ${message}
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
function printJson(value) {
|
|
25
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/lib/banner.ts
|
|
29
|
+
import chalk2 from "chalk";
|
|
30
|
+
function printBanner(args) {
|
|
31
|
+
if (args.json) return;
|
|
32
|
+
if (process.env.NO_GENI_BANNER === "1") return;
|
|
33
|
+
if (!process.stdout.isTTY) return;
|
|
34
|
+
if (!args.session) return;
|
|
35
|
+
const ws = args.session.workspace;
|
|
36
|
+
const parts = [
|
|
37
|
+
chalk2.dim("geni"),
|
|
38
|
+
chalk2.dim("\xB7"),
|
|
39
|
+
chalk2.dim("workspace:"),
|
|
40
|
+
chalk2.cyan(ws.slug)
|
|
41
|
+
];
|
|
42
|
+
if (args.workflowId) {
|
|
43
|
+
parts.push(
|
|
44
|
+
chalk2.dim("\xB7"),
|
|
45
|
+
chalk2.dim("workflow:"),
|
|
46
|
+
chalk2.cyan(args.workflowId)
|
|
47
|
+
);
|
|
48
|
+
if (args.workflowName) {
|
|
49
|
+
parts.push(chalk2.dim(`(${args.workflowName})`));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
process.stderr.write(parts.join(" ") + "\n");
|
|
53
|
+
const titleParts = [`geni \xB7 ${ws.slug}`];
|
|
54
|
+
if (args.workflowId) titleParts.push(args.workflowId);
|
|
55
|
+
process.stderr.write(`\x1B]0;${titleParts.join(" \xB7 ")}\x07`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/lib/exitCodes.ts
|
|
59
|
+
var ExitCode = {
|
|
60
|
+
Ok: 0,
|
|
61
|
+
GenericError: 1,
|
|
62
|
+
InvalidArgs: 2,
|
|
63
|
+
NotFound: 4,
|
|
64
|
+
Forbidden: 5,
|
|
65
|
+
ValidationFailed: 9,
|
|
66
|
+
CredentialResolveFailed: 77,
|
|
67
|
+
SessionMissingOrExpired: 78,
|
|
68
|
+
UpgradeRequired: 79,
|
|
69
|
+
Timeout: 124,
|
|
70
|
+
InternalError: 125
|
|
71
|
+
};
|
|
72
|
+
function exit(code) {
|
|
73
|
+
process.exit(code);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/services/SessionContextService.ts
|
|
77
|
+
var SessionContextService = class {
|
|
78
|
+
constructor(sessionStore2, apiClientFactory2) {
|
|
79
|
+
this.sessionStore = sessionStore2;
|
|
80
|
+
this.apiClientFactory = apiClientFactory2;
|
|
81
|
+
}
|
|
82
|
+
sessionStore;
|
|
83
|
+
apiClientFactory;
|
|
84
|
+
/** Read the session file, returning `null` if no session exists. */
|
|
85
|
+
load() {
|
|
86
|
+
return this.sessionStore.load();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Load the session and build an authed API-client bundle. Exits
|
|
90
|
+
* with code 78 + a "run `geni login`" hint when no session exists.
|
|
91
|
+
*
|
|
92
|
+
* Also prints the workspace banner on stderr (via `printBanner`,
|
|
93
|
+
* which TTY-suppresses + has the `NO_GENI_BANNER` escape hatch),
|
|
94
|
+
* so the operator always sees which workspace the command is
|
|
95
|
+
* targeting before any output appears.
|
|
96
|
+
*/
|
|
97
|
+
async requireAuthed() {
|
|
98
|
+
const session = await this.sessionStore.load();
|
|
99
|
+
if (!session) {
|
|
100
|
+
printError(
|
|
101
|
+
"No runner session on disk. Run `geni login` to authenticate, then retry."
|
|
102
|
+
);
|
|
103
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
104
|
+
}
|
|
105
|
+
printBanner({ session });
|
|
106
|
+
return {
|
|
107
|
+
session,
|
|
108
|
+
client: this.apiClientFactory.build({
|
|
109
|
+
server: session.server,
|
|
110
|
+
token: session.token
|
|
111
|
+
})
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// src/services/AuthService.ts
|
|
117
|
+
import { hostname } from "os";
|
|
118
|
+
import chalk3 from "chalk";
|
|
119
|
+
var AuthService = class {
|
|
120
|
+
constructor(apiClientFactory2, sessionStore2, browserOpener2, configService2) {
|
|
121
|
+
this.apiClientFactory = apiClientFactory2;
|
|
122
|
+
this.sessionStore = sessionStore2;
|
|
123
|
+
this.browserOpener = browserOpener2;
|
|
124
|
+
this.configService = configService2;
|
|
125
|
+
}
|
|
126
|
+
apiClientFactory;
|
|
127
|
+
sessionStore;
|
|
128
|
+
browserOpener;
|
|
129
|
+
configService;
|
|
130
|
+
/**
|
|
131
|
+
* Run the device-code login flow end-to-end. Mints a runner-session
|
|
132
|
+
* token, saves it locally, and (when `--workspace <slug>` was
|
|
133
|
+
* passed) re-binds the session to a different workspace than the
|
|
134
|
+
* one the dashboard's approval picker chose.
|
|
135
|
+
*/
|
|
136
|
+
async login(args) {
|
|
137
|
+
const server = this.configService.resolveApiUrl(args.server);
|
|
138
|
+
const client = this.apiClientFactory.build({ server, token: null });
|
|
139
|
+
const start = await client.auth.startDeviceCode(buildClientLabel());
|
|
140
|
+
printInfo(`Opening ${chalk3.cyan(start.verificationUri)}`);
|
|
141
|
+
printInfo("Approve in your browser to continue.");
|
|
142
|
+
this.browserOpener.open(start.verificationUri);
|
|
143
|
+
const result = await this.pollUntilResolved(client, start);
|
|
144
|
+
if (result.status === "denied") {
|
|
145
|
+
printError(
|
|
146
|
+
'Login was declined in the browser. Run `geni login` again and click "Authorize" on the device-code page.'
|
|
147
|
+
);
|
|
148
|
+
exit(ExitCode.Forbidden);
|
|
149
|
+
}
|
|
150
|
+
if (result.status === "expired") {
|
|
151
|
+
printError(
|
|
152
|
+
"Device code expired before the browser approved it (codes live ~10 minutes). Run `geni login` to start a fresh code."
|
|
153
|
+
);
|
|
154
|
+
exit(ExitCode.GenericError);
|
|
155
|
+
}
|
|
156
|
+
if ("tokenAlreadyConsumed" in result) {
|
|
157
|
+
printError(
|
|
158
|
+
"Login approved but a parallel `geni login` already consumed the device code (race). Run `geni login` again to mint a new session."
|
|
159
|
+
);
|
|
160
|
+
exit(ExitCode.GenericError);
|
|
161
|
+
}
|
|
162
|
+
await this.sessionStore.save({
|
|
163
|
+
version: 1,
|
|
164
|
+
server,
|
|
165
|
+
token: result.sessionToken,
|
|
166
|
+
user: {
|
|
167
|
+
id: result.me.user.id,
|
|
168
|
+
email: result.me.user.email ?? null,
|
|
169
|
+
name: result.me.user.name ?? null
|
|
170
|
+
},
|
|
171
|
+
workspace: {
|
|
172
|
+
membershipId: result.me.workspace.membershipId,
|
|
173
|
+
organizationId: result.me.workspace.organizationId,
|
|
174
|
+
slug: result.me.workspace.slug,
|
|
175
|
+
name: result.me.workspace.name,
|
|
176
|
+
role: result.me.workspace.role
|
|
177
|
+
},
|
|
178
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
179
|
+
});
|
|
180
|
+
if (args.workspace) {
|
|
181
|
+
await this.maybeRebindWorkspace({
|
|
182
|
+
server,
|
|
183
|
+
requestedSlug: args.workspace
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const final = await this.sessionStore.load();
|
|
187
|
+
if (!final) {
|
|
188
|
+
printError(
|
|
189
|
+
"Session was written to ~/.config/geni/runner-session.json but the file could not be re-read immediately. Check filesystem permissions on ~/.config/geni and re-run `geni login`."
|
|
190
|
+
);
|
|
191
|
+
exit(ExitCode.InternalError);
|
|
192
|
+
}
|
|
193
|
+
printSuccess(`Authenticated as ${final.user.email ?? final.user.id}`);
|
|
194
|
+
printSuccess(
|
|
195
|
+
`Active workspace: ${final.workspace.slug} (${final.workspace.name})`
|
|
196
|
+
);
|
|
197
|
+
printInfo("Session saved to ~/.config/geni/runner-session.json");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Revoke the runner session server-side and delete the local file.
|
|
201
|
+
* The local file is removed even if the server-side revoke fails:
|
|
202
|
+
* the operator running `geni logout` should never be left with a
|
|
203
|
+
* token they think is gone but is still on disk.
|
|
204
|
+
*/
|
|
205
|
+
async logout() {
|
|
206
|
+
const session = await this.sessionStore.load();
|
|
207
|
+
if (!session) {
|
|
208
|
+
printInfo("No active session.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const client = this.apiClientFactory.build({
|
|
213
|
+
server: session.server,
|
|
214
|
+
token: session.token
|
|
215
|
+
});
|
|
216
|
+
await client.auth.logout();
|
|
217
|
+
printSuccess("Session revoked.");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
+
printInfo(`Server revoke failed: ${message}`);
|
|
221
|
+
printInfo("Removing local session anyway.");
|
|
222
|
+
}
|
|
223
|
+
await this.sessionStore.delete();
|
|
224
|
+
printSuccess("Removed ~/.config/geni/runner-session.json");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Verify the active session by hitting `/cli/auth/me`. Returns the
|
|
228
|
+
* resolved status when the session is valid, or `null` when no
|
|
229
|
+
* local session exists. Stale-session failures throw `ApiError(401)`
|
|
230
|
+
* so commands can map them to exit code 78 with the same
|
|
231
|
+
* "run `geni login`" hint as the no-session path.
|
|
232
|
+
*/
|
|
233
|
+
async status() {
|
|
234
|
+
const session = await this.sessionStore.load();
|
|
235
|
+
if (!session) return null;
|
|
236
|
+
const client = this.apiClientFactory.build({
|
|
237
|
+
server: session.server,
|
|
238
|
+
token: session.token
|
|
239
|
+
});
|
|
240
|
+
const me = await client.auth.me();
|
|
241
|
+
return {
|
|
242
|
+
authenticated: true,
|
|
243
|
+
user: {
|
|
244
|
+
id: me.user.id,
|
|
245
|
+
email: me.user.email ?? null,
|
|
246
|
+
name: me.user.name ?? null
|
|
247
|
+
},
|
|
248
|
+
workspace: me.workspace,
|
|
249
|
+
server: session.server
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Re-bind the active session to a different workspace by slug. Used
|
|
254
|
+
* by `geni login --workspace <slug>` after the dashboard's approval
|
|
255
|
+
* picker chose a different workspace than the operator wanted.
|
|
256
|
+
*/
|
|
257
|
+
async maybeRebindWorkspace(args) {
|
|
258
|
+
const session = await this.sessionStore.load();
|
|
259
|
+
if (!session) return;
|
|
260
|
+
if (args.requestedSlug === session.workspace.slug) return;
|
|
261
|
+
const client = this.apiClientFactory.build({
|
|
262
|
+
server: args.server,
|
|
263
|
+
token: session.token
|
|
264
|
+
});
|
|
265
|
+
const list = await client.workspaces.list();
|
|
266
|
+
const target = list.workspaces.find((w) => w.slug === args.requestedSlug);
|
|
267
|
+
if (!target) {
|
|
268
|
+
const available = list.workspaces.map((w) => w.slug).join(", ") || "none";
|
|
269
|
+
printError(
|
|
270
|
+
`No workspace with slug "${args.requestedSlug}" on this account. Available: [${available}]. Re-run \`geni login --workspace <slug>\` with one of those.`
|
|
271
|
+
);
|
|
272
|
+
exit(ExitCode.NotFound);
|
|
273
|
+
}
|
|
274
|
+
const me = await client.workspaces.switch(target.membershipId);
|
|
275
|
+
await this.persistSwitch({ session, me });
|
|
276
|
+
}
|
|
277
|
+
async persistSwitch(args) {
|
|
278
|
+
await this.sessionStore.save({
|
|
279
|
+
...args.session,
|
|
280
|
+
workspace: {
|
|
281
|
+
membershipId: args.me.workspace.membershipId,
|
|
282
|
+
organizationId: args.me.workspace.organizationId,
|
|
283
|
+
slug: args.me.workspace.slug,
|
|
284
|
+
name: args.me.workspace.name,
|
|
285
|
+
role: args.me.workspace.role
|
|
286
|
+
},
|
|
287
|
+
user: {
|
|
288
|
+
id: args.me.user.id,
|
|
289
|
+
email: args.me.user.email ?? args.session.user.email,
|
|
290
|
+
name: args.me.user.name ?? args.session.user.name
|
|
291
|
+
},
|
|
292
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Poll the device-code endpoint until status flips to a terminal
|
|
297
|
+
* value, or until the device code itself expires server-side. The
|
|
298
|
+
* `pending` variant is normalized away here — callers only see
|
|
299
|
+
* approved / denied / expired.
|
|
300
|
+
*/
|
|
301
|
+
async pollUntilResolved(client, start) {
|
|
302
|
+
const intervalMs = start.intervalSeconds * 1e3;
|
|
303
|
+
const expiresAtMs = Date.parse(start.expiresAt);
|
|
304
|
+
const hardDeadline = expiresAtMs + 3e4;
|
|
305
|
+
while (Date.now() < hardDeadline) {
|
|
306
|
+
await sleep(intervalMs);
|
|
307
|
+
const status = await client.auth.pollDeviceCode(start.userCode);
|
|
308
|
+
if (status.status === "pending") continue;
|
|
309
|
+
return status;
|
|
310
|
+
}
|
|
311
|
+
return { status: "expired" };
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
function buildClientLabel() {
|
|
315
|
+
return `geni CLI on ${hostname()}`;
|
|
316
|
+
}
|
|
317
|
+
function sleep(ms) {
|
|
318
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/services/WorkspaceService.ts
|
|
322
|
+
var WorkspaceService = class {
|
|
323
|
+
constructor(sessionContext, sessionStore2) {
|
|
324
|
+
this.sessionContext = sessionContext;
|
|
325
|
+
this.sessionStore = sessionStore2;
|
|
326
|
+
}
|
|
327
|
+
sessionContext;
|
|
328
|
+
sessionStore;
|
|
329
|
+
/** Server-side list of every workspace the account belongs to. */
|
|
330
|
+
async list() {
|
|
331
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
332
|
+
return client.workspaces.list();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Switch the active workspace. Re-points the runner session
|
|
336
|
+
* server-side and updates the local cached pointer so subsequent
|
|
337
|
+
* commands operate against the new workspace.
|
|
338
|
+
*
|
|
339
|
+
* Returns:
|
|
340
|
+
* - `{ kind: 'switched', workspace }` on success,
|
|
341
|
+
* - `{ kind: 'no-change', workspace }` when the requested target
|
|
342
|
+
* is already the active one (lets the command print a friendly
|
|
343
|
+
* message without an unnecessary network round-trip),
|
|
344
|
+
* - `{ kind: 'not-found', requestedSlug }` when the slug isn't
|
|
345
|
+
* in the user's accessible workspaces. Commands map this to
|
|
346
|
+
* exit code 4.
|
|
347
|
+
*/
|
|
348
|
+
async switch(args) {
|
|
349
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
350
|
+
const me = await client.workspaces.switch(args.membershipId);
|
|
351
|
+
await this.sessionStore.updateActiveWorkspace({
|
|
352
|
+
membershipId: me.workspace.membershipId,
|
|
353
|
+
organizationId: me.workspace.organizationId,
|
|
354
|
+
slug: me.workspace.slug,
|
|
355
|
+
name: me.workspace.name,
|
|
356
|
+
role: me.workspace.role
|
|
357
|
+
});
|
|
358
|
+
return me;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Read the active workspace from the local session cache (no
|
|
362
|
+
* network round-trip). Returns `null` when no session is loaded;
|
|
363
|
+
* the command renders the unauthenticated case.
|
|
364
|
+
*/
|
|
365
|
+
async current() {
|
|
366
|
+
const session = await this.sessionStore.load();
|
|
367
|
+
if (!session) return null;
|
|
368
|
+
return {
|
|
369
|
+
workspace: {
|
|
370
|
+
membershipId: session.workspace.membershipId,
|
|
371
|
+
organizationId: session.workspace.organizationId,
|
|
372
|
+
slug: session.workspace.slug,
|
|
373
|
+
name: session.workspace.name,
|
|
374
|
+
role: session.workspace.role,
|
|
375
|
+
isActive: true
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/services/ExecService.ts
|
|
382
|
+
import chalk4 from "chalk";
|
|
383
|
+
|
|
384
|
+
// package.json
|
|
385
|
+
var package_default = {
|
|
386
|
+
name: "@general-input/cli",
|
|
387
|
+
version: "0.1.0",
|
|
388
|
+
type: "module",
|
|
389
|
+
description: "The agent-facing CLI for General Input. Authenticate, manage workflows, run bash with operator credentials injected by the cloud.",
|
|
390
|
+
license: "SEE LICENSE IN LICENSE",
|
|
391
|
+
homepage: "https://generalinput.com/docs/cli",
|
|
392
|
+
keywords: [
|
|
393
|
+
"general-input",
|
|
394
|
+
"geni",
|
|
395
|
+
"cli",
|
|
396
|
+
"agent",
|
|
397
|
+
"ai",
|
|
398
|
+
"automation"
|
|
399
|
+
],
|
|
400
|
+
engines: {
|
|
401
|
+
node: ">=20"
|
|
402
|
+
},
|
|
403
|
+
publishConfig: {
|
|
404
|
+
access: "public"
|
|
405
|
+
},
|
|
406
|
+
bin: {
|
|
407
|
+
geni: "./dist/cli.js"
|
|
408
|
+
},
|
|
409
|
+
files: [
|
|
410
|
+
"dist",
|
|
411
|
+
"README.md",
|
|
412
|
+
"LICENSE"
|
|
413
|
+
],
|
|
414
|
+
scripts: {
|
|
415
|
+
dev: "tsup --watch",
|
|
416
|
+
build: "tsup",
|
|
417
|
+
clean: "rm -rf dist",
|
|
418
|
+
typecheck: "tsc --noEmit",
|
|
419
|
+
lint: "eslint . --fix --max-warnings 0",
|
|
420
|
+
format: "prettier --write . --ignore-path=../../.prettierignore",
|
|
421
|
+
test: "vitest run",
|
|
422
|
+
"test:watch": "vitest",
|
|
423
|
+
prepublishOnly: "pnpm build"
|
|
424
|
+
},
|
|
425
|
+
dependencies: {
|
|
426
|
+
"@clack/prompts": "^0.7.0",
|
|
427
|
+
chalk: "^5.3.0",
|
|
428
|
+
commander: "^12.1.0",
|
|
429
|
+
tar: "^7.4.3",
|
|
430
|
+
zod: "^4.3.6"
|
|
431
|
+
},
|
|
432
|
+
devDependencies: {
|
|
433
|
+
"@packages/api": "workspace:*",
|
|
434
|
+
"@packages/eslint-config": "workspace:*",
|
|
435
|
+
"@packages/typescript-config": "workspace:*",
|
|
436
|
+
"@types/node": "^24.12.2",
|
|
437
|
+
"@types/tar": "^6.1.13",
|
|
438
|
+
eslint: "^9.39.4",
|
|
439
|
+
tsup: "^8.3.5",
|
|
440
|
+
typescript: "^5.9.3",
|
|
441
|
+
vitest: "^4.1.5"
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// src/lib/version.ts
|
|
446
|
+
var CLI_VERSION = package_default.version;
|
|
447
|
+
|
|
448
|
+
// src/clients/HttpClient.ts
|
|
449
|
+
var USER_AGENT = `geni/${CLI_VERSION} (${process.platform}/${process.arch}; node/${process.versions.node})`;
|
|
450
|
+
var ApiError = class extends Error {
|
|
451
|
+
constructor(message, status, body) {
|
|
452
|
+
super(message);
|
|
453
|
+
this.status = status;
|
|
454
|
+
this.body = body;
|
|
455
|
+
this.name = "ApiError";
|
|
456
|
+
}
|
|
457
|
+
status;
|
|
458
|
+
body;
|
|
459
|
+
};
|
|
460
|
+
var HttpClient = class {
|
|
461
|
+
constructor(server, token) {
|
|
462
|
+
this.server = server;
|
|
463
|
+
this.token = token;
|
|
464
|
+
}
|
|
465
|
+
server;
|
|
466
|
+
token;
|
|
467
|
+
/**
|
|
468
|
+
* Fetch a JSON endpoint. Caller owns the response shape via the
|
|
469
|
+
* generic; the wrapper returns the parsed JSON cast to `T`. Routes
|
|
470
|
+
* that need authentication MUST be reached via a client built with
|
|
471
|
+
* a non-null token; the wrapper does not enforce auth itself, that's
|
|
472
|
+
* the per-route client's job (each can `requireAuthed()` if needed).
|
|
473
|
+
*/
|
|
474
|
+
async fetch(path, opts = {}) {
|
|
475
|
+
const url = `${this.server}${path}`;
|
|
476
|
+
const headers = {
|
|
477
|
+
"Content-Type": "application/json",
|
|
478
|
+
"User-Agent": USER_AGENT
|
|
479
|
+
};
|
|
480
|
+
if (this.token !== null) {
|
|
481
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
482
|
+
}
|
|
483
|
+
const response = await fetch(url, {
|
|
484
|
+
method: opts.method ?? "GET",
|
|
485
|
+
headers,
|
|
486
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
|
|
487
|
+
signal: opts.signal
|
|
488
|
+
});
|
|
489
|
+
return parseResponse(response);
|
|
490
|
+
}
|
|
491
|
+
/** True when this transport carries a bound runner-session token. */
|
|
492
|
+
get isAuthed() {
|
|
493
|
+
return this.token !== null;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Throw `ApiError(401)` locally when the transport has no token.
|
|
497
|
+
* Per-route clients call this before authed endpoints so a missing
|
|
498
|
+
* session surfaces with the same shape the server would produce for
|
|
499
|
+
* a missing Authorization header, without a network round-trip.
|
|
500
|
+
*/
|
|
501
|
+
requireAuthed() {
|
|
502
|
+
if (this.token !== null) return;
|
|
503
|
+
throw new ApiError(
|
|
504
|
+
"No runner session on disk. Run `geni login` to authenticate, then retry.",
|
|
505
|
+
401,
|
|
506
|
+
null
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
async function parseResponse(response) {
|
|
511
|
+
const text = await response.text();
|
|
512
|
+
let body = null;
|
|
513
|
+
if (text.length > 0) {
|
|
514
|
+
try {
|
|
515
|
+
body = JSON.parse(text);
|
|
516
|
+
} catch {
|
|
517
|
+
body = text;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (!response.ok) {
|
|
521
|
+
const errorField = body !== null && typeof body === "object" && "error" in body ? body.error : null;
|
|
522
|
+
const message = typeof errorField === "string" ? errorField : response.statusText;
|
|
523
|
+
throw new ApiError(
|
|
524
|
+
typeof message === "string" && message.length > 0 ? message : `HTTP ${response.status}`,
|
|
525
|
+
response.status,
|
|
526
|
+
body
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return body;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/lib/scrubber.ts
|
|
533
|
+
var MIN_SECRET_LEN = 8;
|
|
534
|
+
var Scrubber = class {
|
|
535
|
+
/** Map of secret value → redaction marker. Shared across streams. */
|
|
536
|
+
redactions = /* @__PURE__ */ new Map();
|
|
537
|
+
/** Length of the longest registered secret. Sets the tail size. */
|
|
538
|
+
maxLen = 0;
|
|
539
|
+
register(args) {
|
|
540
|
+
const { credentialId, value } = args;
|
|
541
|
+
if (value.length < MIN_SECRET_LEN) return;
|
|
542
|
+
if (this.redactions.has(value)) return;
|
|
543
|
+
this.redactions.set(value, `[REDACTED:credential_${credentialId}]`);
|
|
544
|
+
if (value.length > this.maxLen) this.maxLen = value.length;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Register the literal secret plus the common encoded forms a child
|
|
548
|
+
* process might emit instead of the raw value: base64, base64url, hex
|
|
549
|
+
* (upper + lower), URL-percent-encoded. Catches the lazy obfuscation
|
|
550
|
+
* class of leak (`echo $TOKEN | base64`, `echo $TOKEN | xxd`,
|
|
551
|
+
* `printf '%s' $TOKEN | jq -R @uri`).
|
|
552
|
+
*
|
|
553
|
+
* Does not catch determined adversaries: anything that transforms
|
|
554
|
+
* via `tr`, splits below MIN_SECRET_LEN, or exfiltrates over the
|
|
555
|
+
* network is out of scope for an output scrubber. For airtight
|
|
556
|
+
* isolation the plaintext must not enter the child's env at all.
|
|
557
|
+
*/
|
|
558
|
+
registerWithEncodings(args) {
|
|
559
|
+
this.register(args);
|
|
560
|
+
for (const variant of encodingVariants(args.value)) {
|
|
561
|
+
this.register({ credentialId: args.credentialId, value: variant });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Build a per-stream scrubber view. Each stream gets its own rolling
|
|
566
|
+
* tail buffer; the underlying secret list is shared by reference. The
|
|
567
|
+
* spawner calls this twice per child (stdout + stderr) so the two
|
|
568
|
+
* pipes don't clobber each other's tail state.
|
|
569
|
+
*/
|
|
570
|
+
stream() {
|
|
571
|
+
return new StreamScrubber(this.redactions, this.maxLen);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Single-shot redaction for callers that have the entire string in
|
|
575
|
+
* hand and don't need the streaming machinery. Useful for tests and
|
|
576
|
+
* for one-off helpers (`apps/server/src/services/SandboxWorkspace`'s
|
|
577
|
+
* non-streaming surfaces). Equivalent to creating a stream, redacting
|
|
578
|
+
* once with `final: true`, and discarding.
|
|
579
|
+
*/
|
|
580
|
+
redact(text) {
|
|
581
|
+
return this.stream().redact(text, { final: true });
|
|
582
|
+
}
|
|
583
|
+
/** Test-only: how many secrets are registered. */
|
|
584
|
+
get size() {
|
|
585
|
+
return this.redactions.size;
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
var StreamScrubber = class {
|
|
589
|
+
constructor(redactions, maxLen) {
|
|
590
|
+
this.redactions = redactions;
|
|
591
|
+
this.maxLen = maxLen;
|
|
592
|
+
}
|
|
593
|
+
redactions;
|
|
594
|
+
maxLen;
|
|
595
|
+
tail = "";
|
|
596
|
+
/**
|
|
597
|
+
* Redact a chunk and return the safe-to-emit portion. The trailing
|
|
598
|
+
* `maxLen - 1` chars are buffered for the next call so a secret that
|
|
599
|
+
* straddles the chunk boundary still gets caught.
|
|
600
|
+
*
|
|
601
|
+
* Pass `final: true` on end-of-stream to flush the buffered tail.
|
|
602
|
+
*/
|
|
603
|
+
redact(chunk, opts = {}) {
|
|
604
|
+
if (this.redactions.size === 0) {
|
|
605
|
+
if (opts.final) {
|
|
606
|
+
const out = this.tail + chunk;
|
|
607
|
+
this.tail = "";
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
return chunk;
|
|
611
|
+
}
|
|
612
|
+
const combined = this.tail + chunk;
|
|
613
|
+
const redacted = this.replaceAll(combined);
|
|
614
|
+
if (opts.final) {
|
|
615
|
+
this.tail = "";
|
|
616
|
+
return redacted;
|
|
617
|
+
}
|
|
618
|
+
const holdback = Math.max(0, this.maxLen - 1);
|
|
619
|
+
if (redacted.length <= holdback) {
|
|
620
|
+
this.tail = redacted;
|
|
621
|
+
return "";
|
|
622
|
+
}
|
|
623
|
+
const cut = redacted.length - holdback;
|
|
624
|
+
this.tail = redacted.slice(cut);
|
|
625
|
+
return redacted.slice(0, cut);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Replace every registered secret in `text`. Iterates longest-first
|
|
629
|
+
* so a superstring secret gets redacted before any of its substrings,
|
|
630
|
+
* which prevents partial overlaps from sneaking through.
|
|
631
|
+
*/
|
|
632
|
+
replaceAll(text) {
|
|
633
|
+
let result = text;
|
|
634
|
+
const entries = [...this.redactions.entries()].sort(
|
|
635
|
+
(a, b) => b[0].length - a[0].length
|
|
636
|
+
);
|
|
637
|
+
for (const [value, marker] of entries) {
|
|
638
|
+
if (!result.includes(value)) continue;
|
|
639
|
+
result = result.split(value).join(marker);
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
function encodingVariants(value) {
|
|
645
|
+
const buf = Buffer.from(value, "utf8");
|
|
646
|
+
return [
|
|
647
|
+
buf.toString("base64"),
|
|
648
|
+
buf.toString("base64url"),
|
|
649
|
+
buf.toString("hex"),
|
|
650
|
+
buf.toString("hex").toUpperCase(),
|
|
651
|
+
encodeURIComponent(value)
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/lib/execEnv.ts
|
|
656
|
+
var SAFE_INHERIT_ENV = [
|
|
657
|
+
// Process essentials.
|
|
658
|
+
"PATH",
|
|
659
|
+
"HOME",
|
|
660
|
+
"USER",
|
|
661
|
+
"LOGNAME",
|
|
662
|
+
"SHELL",
|
|
663
|
+
"PWD",
|
|
664
|
+
// Locale.
|
|
665
|
+
"LANG",
|
|
666
|
+
"LC_ALL",
|
|
667
|
+
"LC_CTYPE",
|
|
668
|
+
"TZ",
|
|
669
|
+
// Terminal capabilities. Without these, color output and TUI
|
|
670
|
+
// programs render garbled.
|
|
671
|
+
"TERM",
|
|
672
|
+
"COLORTERM",
|
|
673
|
+
"LINES",
|
|
674
|
+
"COLUMNS",
|
|
675
|
+
// Tempfile location.
|
|
676
|
+
"TMPDIR"
|
|
677
|
+
];
|
|
678
|
+
function buildSafeInheritedEnv() {
|
|
679
|
+
const out = {};
|
|
680
|
+
for (const key of SAFE_INHERIT_ENV) {
|
|
681
|
+
const value = process.env[key];
|
|
682
|
+
if (value !== void 0) out[key] = value;
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/services/ExecService.ts
|
|
688
|
+
var ExecService = class {
|
|
689
|
+
constructor(sessionContext, spawner) {
|
|
690
|
+
this.sessionContext = sessionContext;
|
|
691
|
+
this.spawner = spawner;
|
|
692
|
+
}
|
|
693
|
+
sessionContext;
|
|
694
|
+
spawner;
|
|
695
|
+
async runBash(args) {
|
|
696
|
+
const { resolved, scrubber } = await this.resolveAndScrub(
|
|
697
|
+
args.credentials,
|
|
698
|
+
args.quiet
|
|
699
|
+
);
|
|
700
|
+
const env = {
|
|
701
|
+
...buildSafeInheritedEnv(),
|
|
702
|
+
PLATFORM_API_KEY: resolved.platformApiKey,
|
|
703
|
+
PLATFORM_BASE_URL: resolved.platformBaseUrl
|
|
704
|
+
};
|
|
705
|
+
for (const cred of resolved.credentials) {
|
|
706
|
+
Object.assign(env, cred.envVars);
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
return await this.spawner.run({
|
|
710
|
+
command: "bash",
|
|
711
|
+
args: ["-lc", args.command],
|
|
712
|
+
env,
|
|
713
|
+
cwd: args.cwd,
|
|
714
|
+
scrubber
|
|
715
|
+
});
|
|
716
|
+
} catch (err) {
|
|
717
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
718
|
+
printError(
|
|
719
|
+
`Could not spawn \`bash\` (${detail}). Verify bash is on $PATH with \`command -v bash\`. Credentials were already resolved (audit-logged); the subprocess never started.`
|
|
720
|
+
);
|
|
721
|
+
return ExitCode.InternalError;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Resolve credentials, register their secrets with a streaming
|
|
726
|
+
* scrubber, and print per-credential status lines unless quiet.
|
|
727
|
+
* Exits the process on resolve failure (the documented exit codes
|
|
728
|
+
* are a CLI-level contract — there's no useful recovery path above
|
|
729
|
+
* this).
|
|
730
|
+
*/
|
|
731
|
+
async resolveAndScrub(credentials, quiet) {
|
|
732
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
733
|
+
let resolved;
|
|
734
|
+
try {
|
|
735
|
+
resolved = await client.exec.resolve({ credentials });
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (error instanceof ApiError) {
|
|
738
|
+
const ids = credentials.map((c) => c.id).join(", ") || "(none)";
|
|
739
|
+
if (error.status === 403) {
|
|
740
|
+
printError(
|
|
741
|
+
`Cloud refused credential resolution: ${error.message} Declared ids: [${ids}]. Verify each one with \`geni credential list\`.`
|
|
742
|
+
);
|
|
743
|
+
exit(ExitCode.CredentialResolveFailed);
|
|
744
|
+
}
|
|
745
|
+
if (error.status === 401) {
|
|
746
|
+
printError(
|
|
747
|
+
`Runner session is missing or expired: ${error.message} Run \`geni login\` to re-authenticate, then retry.`
|
|
748
|
+
);
|
|
749
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
750
|
+
}
|
|
751
|
+
printError(
|
|
752
|
+
`Cloud failed to resolve credentials (HTTP ${error.status}): ${error.message} The subprocess did not start. Retry once before reporting.`
|
|
753
|
+
);
|
|
754
|
+
exit(ExitCode.InternalError);
|
|
755
|
+
}
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
const scrubber = new Scrubber();
|
|
759
|
+
scrubber.registerWithEncodings({
|
|
760
|
+
credentialId: "platform",
|
|
761
|
+
value: resolved.platformApiKey
|
|
762
|
+
});
|
|
763
|
+
for (const cred of resolved.credentials) {
|
|
764
|
+
for (const value of cred.redactionValues) {
|
|
765
|
+
scrubber.registerWithEncodings({
|
|
766
|
+
credentialId: cred.credentialId,
|
|
767
|
+
value
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (!quiet) this.printResolvedStatusLines(resolved);
|
|
772
|
+
return { resolved, scrubber };
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Print one stderr status line per resolved credential plus one
|
|
776
|
+
* per cred error. Stays on stderr so stdout remains clean for
|
|
777
|
+
* pipe-friendly subprocess output.
|
|
778
|
+
*/
|
|
779
|
+
printResolvedStatusLines(resolved) {
|
|
780
|
+
for (const cred of resolved.credentials) {
|
|
781
|
+
const envList = Object.keys(cred.envVars).sort().join(", ");
|
|
782
|
+
printInfo(
|
|
783
|
+
`resolved ${chalk4.cyan(cred.credentialId)} (${cred.providerTitle}, ${cred.credentialTitle}) \u2192 ${envList}`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
for (const err of resolved.errors ?? []) {
|
|
787
|
+
printError(
|
|
788
|
+
`${err.credentialId} (${err.providerTitle}): ${err.message} The subprocess will run without this credential \u2014 calls that need it will 401. Re-auth ${err.providerTitle} from the dashboard.`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// src/services/DiscoveryService.ts
|
|
795
|
+
var DiscoveryService = class {
|
|
796
|
+
constructor(sessionContext, browserOpener2, configService2) {
|
|
797
|
+
this.sessionContext = sessionContext;
|
|
798
|
+
this.browserOpener = browserOpener2;
|
|
799
|
+
this.configService = configService2;
|
|
800
|
+
}
|
|
801
|
+
sessionContext;
|
|
802
|
+
browserOpener;
|
|
803
|
+
configService;
|
|
804
|
+
// ---- credentials ----------------------------------------------------
|
|
805
|
+
async listCredentials(args) {
|
|
806
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
807
|
+
const { credentials } = await client.credentials.list();
|
|
808
|
+
let result = credentials;
|
|
809
|
+
if (args.service) {
|
|
810
|
+
result = result.filter((c) => c.service === args.service);
|
|
811
|
+
}
|
|
812
|
+
if (args.mine) {
|
|
813
|
+
result = result.filter((c) => c.isOwnedByViewer);
|
|
814
|
+
}
|
|
815
|
+
if (args.query && args.query.length > 0) {
|
|
816
|
+
result = rankCredentials(result, args.query);
|
|
817
|
+
}
|
|
818
|
+
return result;
|
|
819
|
+
}
|
|
820
|
+
async getCredential(id) {
|
|
821
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
822
|
+
return client.credentials.get(id);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Validate the service slug against the integration catalog (404
|
|
826
|
+
* surfaces the same way `integration get` does, mapped to exit 4
|
|
827
|
+
* by the command), then return the dashboard connect URL the
|
|
828
|
+
* operator should be sent to.
|
|
829
|
+
*
|
|
830
|
+
* Side effect: if `printUrlOnly` is false, opens the URL in the
|
|
831
|
+
* operator's default browser. The CLI always prints the URL too,
|
|
832
|
+
* so a silent failure to launch a browser still leaves the
|
|
833
|
+
* operator with a clickable link.
|
|
834
|
+
*/
|
|
835
|
+
async connectCredential(args) {
|
|
836
|
+
const { session, client } = await this.sessionContext.requireAuthed();
|
|
837
|
+
await client.integrations.get(args.service);
|
|
838
|
+
const url = `${this.configService.resolveDashboardUrl(session.server)}/credentials/connect?service=${encodeURIComponent(args.service)}`;
|
|
839
|
+
if (args.printUrlOnly) return { kind: "print-url", url };
|
|
840
|
+
this.browserOpener.open(url);
|
|
841
|
+
return { kind: "open-browser", url };
|
|
842
|
+
}
|
|
843
|
+
// ---- integrations ---------------------------------------------------
|
|
844
|
+
async listIntegrations(args) {
|
|
845
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
846
|
+
const { integrations } = await client.integrations.list({
|
|
847
|
+
query: args.query
|
|
848
|
+
});
|
|
849
|
+
return args.type ? integrations.filter((i) => i.credentialType === args.type) : integrations;
|
|
850
|
+
}
|
|
851
|
+
async getIntegration(service) {
|
|
852
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
853
|
+
return client.integrations.get(service);
|
|
854
|
+
}
|
|
855
|
+
// ---- operations -----------------------------------------------------
|
|
856
|
+
async listOperations(args) {
|
|
857
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
858
|
+
const { operations } = await client.integrations.listOperations(
|
|
859
|
+
args.service
|
|
860
|
+
);
|
|
861
|
+
if (!args.query || args.query.length === 0) return operations;
|
|
862
|
+
return rankOperations(operations, args.query);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Look up one operation. When `service` is provided, hits the
|
|
866
|
+
* service-scoped integrations route; otherwise hits the standalone
|
|
867
|
+
* `/cli/operations/:opId` route which lets the server resolve the
|
|
868
|
+
* service from the id alone.
|
|
869
|
+
*/
|
|
870
|
+
async getOperation(args) {
|
|
871
|
+
const { client } = await this.sessionContext.requireAuthed();
|
|
872
|
+
return args.service ? client.integrations.getOperation({
|
|
873
|
+
service: args.service,
|
|
874
|
+
opId: args.opId
|
|
875
|
+
}) : client.operations.getById(args.opId);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
function rankCredentials(credentials, query) {
|
|
879
|
+
const q = query.toLowerCase();
|
|
880
|
+
const scored = credentials.map((c) => {
|
|
881
|
+
let score = 0;
|
|
882
|
+
if (c.service.toLowerCase().includes(q)) score += 3;
|
|
883
|
+
if (c.providerTitle.toLowerCase().includes(q)) score += 2;
|
|
884
|
+
if (c.title.toLowerCase().includes(q)) score += 1;
|
|
885
|
+
return { c, score };
|
|
886
|
+
});
|
|
887
|
+
return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s) => s.c);
|
|
888
|
+
}
|
|
889
|
+
function rankOperations(operations, query) {
|
|
890
|
+
const q = query.toLowerCase();
|
|
891
|
+
const scored = operations.map((op) => {
|
|
892
|
+
let score = 0;
|
|
893
|
+
if (op.title.toLowerCase().includes(q)) score += 2;
|
|
894
|
+
if (op.description.toLowerCase().includes(q)) score += 1;
|
|
895
|
+
return { op, score };
|
|
896
|
+
});
|
|
897
|
+
return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s) => s.op);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/types/config.ts
|
|
901
|
+
import { z } from "zod";
|
|
902
|
+
var CliConfigSchema = z.object({
|
|
903
|
+
version: z.literal(1),
|
|
904
|
+
/**
|
|
905
|
+
* Override for the cloud API base URL used at fresh `geni login`
|
|
906
|
+
* time. Once a session exists, the URL stored on it takes
|
|
907
|
+
* precedence — switching `apiUrl` after login does NOT reroute
|
|
908
|
+
* existing tokens (the session knows which server minted it).
|
|
909
|
+
*/
|
|
910
|
+
apiUrl: z.url().optional(),
|
|
911
|
+
/**
|
|
912
|
+
* Override for the dashboard base URL used by browser-opening
|
|
913
|
+
* commands (`geni credential connect`). Independent of `apiUrl`
|
|
914
|
+
* because in dev they live on different ports.
|
|
915
|
+
*/
|
|
916
|
+
dashboardUrl: z.url().optional()
|
|
917
|
+
});
|
|
918
|
+
var SETTABLE_CONFIG_KEYS = ["apiUrl", "dashboardUrl"];
|
|
919
|
+
var SETTABLE_CONFIG_KEY_SET = new Set(
|
|
920
|
+
SETTABLE_CONFIG_KEYS
|
|
921
|
+
);
|
|
922
|
+
function isSettableConfigKey(key) {
|
|
923
|
+
return SETTABLE_CONFIG_KEY_SET.has(key);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/services/ConfigService.ts
|
|
927
|
+
var DEFAULT_API_URL = "https://cloud.generalinput.com";
|
|
928
|
+
var DEFAULT_DASHBOARD_URL = "https://web.generalinput.com";
|
|
929
|
+
var ConfigService = class {
|
|
930
|
+
constructor(configStore2, sessionStore2) {
|
|
931
|
+
this.configStore = configStore2;
|
|
932
|
+
this.sessionStore = sessionStore2;
|
|
933
|
+
}
|
|
934
|
+
configStore;
|
|
935
|
+
sessionStore;
|
|
936
|
+
/**
|
|
937
|
+
* Resolve the API URL the CLI should talk to. Precedence:
|
|
938
|
+
* 1. The session's stored server (locked at `geni login` time —
|
|
939
|
+
* the auth token was minted on that specific URL).
|
|
940
|
+
* 2. `$GENI_API_URL` env var.
|
|
941
|
+
* 3. `apiUrl` from the persistent config.
|
|
942
|
+
* 4. Compiled-in default.
|
|
943
|
+
*
|
|
944
|
+
* Callers that have a session loaded should pass `sessionServer`
|
|
945
|
+
* explicitly. The session lock means changing the config after
|
|
946
|
+
* login does NOT retarget existing commands until logout + re-login.
|
|
947
|
+
*/
|
|
948
|
+
resolveApiUrl(sessionServer) {
|
|
949
|
+
return sessionServer ?? process.env.GENI_API_URL ?? this.configStore.loadSync()?.apiUrl ?? DEFAULT_API_URL;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Resolve the dashboard URL for browser-opening commands. Precedence:
|
|
953
|
+
* 1. `$GENI_DASHBOARD_URL` env var.
|
|
954
|
+
* 2. `dashboardUrl` from the persistent config.
|
|
955
|
+
* 3. Inferred from the session's API URL when it points at
|
|
956
|
+
* localhost (dev convenience: API on :4111 → dashboard on :5177).
|
|
957
|
+
* 4. Compiled-in default.
|
|
958
|
+
*/
|
|
959
|
+
resolveDashboardUrl(sessionApiUrl) {
|
|
960
|
+
if (process.env.GENI_DASHBOARD_URL) return process.env.GENI_DASHBOARD_URL;
|
|
961
|
+
const config = this.configStore.loadSync();
|
|
962
|
+
if (config?.dashboardUrl) return config.dashboardUrl;
|
|
963
|
+
if (sessionApiUrl?.includes("localhost")) return "http://localhost:5177";
|
|
964
|
+
return DEFAULT_DASHBOARD_URL;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Read what's literally in the persistent config file, with no
|
|
968
|
+
* resolver fallbacks layered on. This is what `set` writes and what
|
|
969
|
+
* `get` should display — symmetric, predictable, no "I set X, get
|
|
970
|
+
* shows Y" surprise from a session-locked URL trumping the file.
|
|
971
|
+
*
|
|
972
|
+
* For "what URL is the CLI actually hitting right now?" the answer
|
|
973
|
+
* lives on the session (printed by `geni auth status`); the resolver
|
|
974
|
+
* itself stays in `resolveApiUrl` / `resolveDashboardUrl`.
|
|
975
|
+
*/
|
|
976
|
+
fileValues() {
|
|
977
|
+
const file = this.configStore.loadSync();
|
|
978
|
+
return {
|
|
979
|
+
apiUrl: file?.apiUrl,
|
|
980
|
+
dashboardUrl: file?.dashboardUrl
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Write a config value. Validates against the schema; a malformed
|
|
985
|
+
* URL fails loudly here rather than waiting for the next CLI
|
|
986
|
+
* command to crash.
|
|
987
|
+
*
|
|
988
|
+
* Refuses to change `apiUrl` while a runner-session is bound to a
|
|
989
|
+
* different URL: the session's server is what the CLI actually hits
|
|
990
|
+
* at runtime, so silently letting the file diverge from that would
|
|
991
|
+
* make `geni config set` a lie. The operator must logout (or use
|
|
992
|
+
* `geni login --server <url>`) to switch servers cleanly.
|
|
993
|
+
*/
|
|
994
|
+
async set(args) {
|
|
995
|
+
const existing = this.configStore.loadSync() ?? { version: 1 };
|
|
996
|
+
const next = { ...existing, [args.key]: args.value };
|
|
997
|
+
const parsed = CliConfigSchema.safeParse(next);
|
|
998
|
+
if (!parsed.success) {
|
|
999
|
+
return {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
reason: "invalid",
|
|
1002
|
+
error: parsed.error.issues[0]?.message ?? "failed validation"
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (args.key === "apiUrl") {
|
|
1006
|
+
const session = await this.sessionStore.load();
|
|
1007
|
+
if (session && session.server !== args.value) {
|
|
1008
|
+
return {
|
|
1009
|
+
ok: false,
|
|
1010
|
+
reason: "session_conflict",
|
|
1011
|
+
sessionUrl: session.server
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
await this.configStore.save(parsed.data);
|
|
1016
|
+
return { ok: true };
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Remove a key. When the last key is removed, the file itself is
|
|
1020
|
+
* deleted so `cat $(geni config path)` doesn't show an empty
|
|
1021
|
+
* `{ "version": 1 }` shell.
|
|
1022
|
+
*
|
|
1023
|
+
* Returns whether the key was actually present (lets the command
|
|
1024
|
+
* pick "Unset apiUrl." vs "apiUrl was already unset.").
|
|
1025
|
+
*/
|
|
1026
|
+
async unset(args) {
|
|
1027
|
+
const existing = this.configStore.loadSync();
|
|
1028
|
+
if (!existing || existing[args.key] === void 0) {
|
|
1029
|
+
return { wasSet: false };
|
|
1030
|
+
}
|
|
1031
|
+
const next = { version: 1 };
|
|
1032
|
+
for (const k of SETTABLE_CONFIG_KEYS) {
|
|
1033
|
+
if (k === args.key) continue;
|
|
1034
|
+
const value = existing[k];
|
|
1035
|
+
if (value !== void 0) next[k] = value;
|
|
1036
|
+
}
|
|
1037
|
+
const hasRemainingValues = SETTABLE_CONFIG_KEYS.some(
|
|
1038
|
+
(k) => next[k] !== void 0
|
|
1039
|
+
);
|
|
1040
|
+
if (hasRemainingValues) {
|
|
1041
|
+
await this.configStore.save(next);
|
|
1042
|
+
} else {
|
|
1043
|
+
await this.configStore.delete();
|
|
1044
|
+
}
|
|
1045
|
+
return { wasSet: true };
|
|
1046
|
+
}
|
|
1047
|
+
/** Absolute path to the config file. */
|
|
1048
|
+
get path() {
|
|
1049
|
+
return this.configStore.path;
|
|
1050
|
+
}
|
|
1051
|
+
/** Re-export the type guard from the types module for convenience. */
|
|
1052
|
+
isSettableKey(key) {
|
|
1053
|
+
return isSettableConfigKey(key);
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
// src/clients/AuthApiClient.ts
|
|
1058
|
+
var AuthApiClient = class {
|
|
1059
|
+
constructor(http) {
|
|
1060
|
+
this.http = http;
|
|
1061
|
+
}
|
|
1062
|
+
http;
|
|
1063
|
+
async startDeviceCode(clientLabel) {
|
|
1064
|
+
return this.http.fetch("/cli/auth/device-code", {
|
|
1065
|
+
method: "POST",
|
|
1066
|
+
body: { clientLabel }
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
async pollDeviceCode(userCode) {
|
|
1070
|
+
return this.http.fetch(
|
|
1071
|
+
`/cli/auth/device-code/${encodeURIComponent(userCode)}/poll`,
|
|
1072
|
+
{ method: "POST" }
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
async me() {
|
|
1076
|
+
this.http.requireAuthed();
|
|
1077
|
+
return this.http.fetch("/cli/auth/me");
|
|
1078
|
+
}
|
|
1079
|
+
async logout() {
|
|
1080
|
+
this.http.requireAuthed();
|
|
1081
|
+
return this.http.fetch("/cli/auth/logout", { method: "POST" });
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
// src/clients/WorkspacesApiClient.ts
|
|
1086
|
+
var WorkspacesApiClient = class {
|
|
1087
|
+
constructor(http) {
|
|
1088
|
+
this.http = http;
|
|
1089
|
+
}
|
|
1090
|
+
http;
|
|
1091
|
+
async list() {
|
|
1092
|
+
this.http.requireAuthed();
|
|
1093
|
+
return this.http.fetch("/cli/workspaces");
|
|
1094
|
+
}
|
|
1095
|
+
async switch(membershipId) {
|
|
1096
|
+
this.http.requireAuthed();
|
|
1097
|
+
return this.http.fetch("/cli/workspaces/switch", {
|
|
1098
|
+
method: "POST",
|
|
1099
|
+
body: { membershipId }
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// src/clients/ExecApiClient.ts
|
|
1105
|
+
var ExecApiClient = class {
|
|
1106
|
+
constructor(http) {
|
|
1107
|
+
this.http = http;
|
|
1108
|
+
}
|
|
1109
|
+
http;
|
|
1110
|
+
async resolve(body) {
|
|
1111
|
+
this.http.requireAuthed();
|
|
1112
|
+
return this.http.fetch("/cli/exec/resolve", {
|
|
1113
|
+
method: "POST",
|
|
1114
|
+
body
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// src/clients/CredentialsApiClient.ts
|
|
1120
|
+
var CredentialsApiClient = class {
|
|
1121
|
+
constructor(http) {
|
|
1122
|
+
this.http = http;
|
|
1123
|
+
}
|
|
1124
|
+
http;
|
|
1125
|
+
async list() {
|
|
1126
|
+
this.http.requireAuthed();
|
|
1127
|
+
return this.http.fetch("/cli/credentials");
|
|
1128
|
+
}
|
|
1129
|
+
async get(id) {
|
|
1130
|
+
this.http.requireAuthed();
|
|
1131
|
+
return this.http.fetch(`/cli/credentials/${encodeURIComponent(id)}`);
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
// src/clients/IntegrationsApiClient.ts
|
|
1136
|
+
var IntegrationsApiClient = class {
|
|
1137
|
+
constructor(http) {
|
|
1138
|
+
this.http = http;
|
|
1139
|
+
}
|
|
1140
|
+
http;
|
|
1141
|
+
async list(args) {
|
|
1142
|
+
this.http.requireAuthed();
|
|
1143
|
+
const path = args?.query && args.query.length > 0 ? `/cli/integrations?q=${encodeURIComponent(args.query)}` : "/cli/integrations";
|
|
1144
|
+
return this.http.fetch(path);
|
|
1145
|
+
}
|
|
1146
|
+
async get(service) {
|
|
1147
|
+
this.http.requireAuthed();
|
|
1148
|
+
return this.http.fetch(`/cli/integrations/${encodeURIComponent(service)}`);
|
|
1149
|
+
}
|
|
1150
|
+
async listOperations(service) {
|
|
1151
|
+
this.http.requireAuthed();
|
|
1152
|
+
return this.http.fetch(
|
|
1153
|
+
`/cli/integrations/${encodeURIComponent(service)}/operations`
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
async getOperation(args) {
|
|
1157
|
+
this.http.requireAuthed();
|
|
1158
|
+
return this.http.fetch(
|
|
1159
|
+
`/cli/integrations/${encodeURIComponent(args.service)}/operations/${encodeURIComponent(args.opId)}`
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
// src/clients/OperationsApiClient.ts
|
|
1165
|
+
var OperationsApiClient = class {
|
|
1166
|
+
constructor(http) {
|
|
1167
|
+
this.http = http;
|
|
1168
|
+
}
|
|
1169
|
+
http;
|
|
1170
|
+
async getById(opId) {
|
|
1171
|
+
this.http.requireAuthed();
|
|
1172
|
+
return this.http.fetch(`/cli/operations/${encodeURIComponent(opId)}`);
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
// src/clients/ApiClientFactory.ts
|
|
1177
|
+
var ApiClientFactory = class {
|
|
1178
|
+
build(args) {
|
|
1179
|
+
const http = new HttpClient(args.server, args.token);
|
|
1180
|
+
return {
|
|
1181
|
+
auth: new AuthApiClient(http),
|
|
1182
|
+
workspaces: new WorkspacesApiClient(http),
|
|
1183
|
+
exec: new ExecApiClient(http),
|
|
1184
|
+
credentials: new CredentialsApiClient(http),
|
|
1185
|
+
integrations: new IntegrationsApiClient(http),
|
|
1186
|
+
operations: new OperationsApiClient(http)
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
// src/clients/SessionStore.ts
|
|
1192
|
+
import { mkdir, readFile, writeFile, unlink, chmod } from "fs/promises";
|
|
1193
|
+
|
|
1194
|
+
// src/types/session.ts
|
|
1195
|
+
import { z as z2 } from "zod";
|
|
1196
|
+
var RunnerSessionFileSchema = z2.object({
|
|
1197
|
+
version: z2.literal(1),
|
|
1198
|
+
server: z2.url(),
|
|
1199
|
+
/** Plaintext runner-session token (`geni_rs_…`). */
|
|
1200
|
+
token: z2.string().startsWith("geni_rs_"),
|
|
1201
|
+
user: z2.object({
|
|
1202
|
+
id: z2.string(),
|
|
1203
|
+
email: z2.string().nullable(),
|
|
1204
|
+
name: z2.string().nullable()
|
|
1205
|
+
}),
|
|
1206
|
+
workspace: z2.object({
|
|
1207
|
+
membershipId: z2.string(),
|
|
1208
|
+
organizationId: z2.string(),
|
|
1209
|
+
slug: z2.string(),
|
|
1210
|
+
name: z2.string(),
|
|
1211
|
+
role: z2.string()
|
|
1212
|
+
}),
|
|
1213
|
+
/** ISO 8601 — when the file was last written by login or workspace switch. */
|
|
1214
|
+
savedAt: z2.string()
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// src/clients/SessionStore.ts
|
|
1218
|
+
var SessionStore = class {
|
|
1219
|
+
constructor(filePath, directoryPath) {
|
|
1220
|
+
this.filePath = filePath;
|
|
1221
|
+
this.directoryPath = directoryPath;
|
|
1222
|
+
}
|
|
1223
|
+
filePath;
|
|
1224
|
+
directoryPath;
|
|
1225
|
+
/**
|
|
1226
|
+
* Read the file, or `null` if no session exists. Returns `null` for
|
|
1227
|
+
* unparseable / schema-invalid files too — corrupt local state
|
|
1228
|
+
* shouldn't prevent re-login. The user can rerun `geni login` to
|
|
1229
|
+
* write a fresh file over the broken one.
|
|
1230
|
+
*/
|
|
1231
|
+
async load() {
|
|
1232
|
+
let raw;
|
|
1233
|
+
try {
|
|
1234
|
+
raw = await readFile(this.filePath, "utf-8");
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (isErrnoCode(err, "ENOENT")) return null;
|
|
1237
|
+
throw err;
|
|
1238
|
+
}
|
|
1239
|
+
let json;
|
|
1240
|
+
try {
|
|
1241
|
+
json = JSON.parse(raw);
|
|
1242
|
+
} catch {
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
const parsed = RunnerSessionFileSchema.safeParse(json);
|
|
1246
|
+
return parsed.success ? parsed.data : null;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Persist the session. Creates the config dir if missing and lands
|
|
1250
|
+
* mode 0600 on the file. Tightens dir mode to 0700 best-effort —
|
|
1251
|
+
* if the chmod fails (e.g. on a CI mount), we continue rather than
|
|
1252
|
+
* failing the login outright.
|
|
1253
|
+
*/
|
|
1254
|
+
async save(session) {
|
|
1255
|
+
await mkdir(this.directoryPath, { recursive: true, mode: 448 });
|
|
1256
|
+
await chmod(this.directoryPath, 448).catch(() => {
|
|
1257
|
+
});
|
|
1258
|
+
await writeFile(this.filePath, JSON.stringify(session, null, 2), {
|
|
1259
|
+
mode: 384
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Delete the session file. Idempotent — missing file is not an error.
|
|
1264
|
+
* Used by `geni logout` after server revoke, and as a recovery path
|
|
1265
|
+
* when the local file is corrupt or stale.
|
|
1266
|
+
*/
|
|
1267
|
+
async delete() {
|
|
1268
|
+
try {
|
|
1269
|
+
await unlink(this.filePath);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
if (isErrnoCode(err, "ENOENT")) return;
|
|
1272
|
+
throw err;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Update just the workspace pointer in the session file, used by
|
|
1277
|
+
* `geni workspace switch` after the server confirms the membership.
|
|
1278
|
+
* Throws if no session exists — the caller must have verified one
|
|
1279
|
+
* is loaded before calling this.
|
|
1280
|
+
*/
|
|
1281
|
+
async updateActiveWorkspace(workspace) {
|
|
1282
|
+
const current = await this.load();
|
|
1283
|
+
if (!current) {
|
|
1284
|
+
throw new Error("No active session to update");
|
|
1285
|
+
}
|
|
1286
|
+
await this.save({
|
|
1287
|
+
...current,
|
|
1288
|
+
workspace,
|
|
1289
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
function isErrnoCode(err, expected) {
|
|
1294
|
+
if (typeof err !== "object" || err === null) return false;
|
|
1295
|
+
if (!("code" in err)) return false;
|
|
1296
|
+
return err.code === expected;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/clients/ConfigStore.ts
|
|
1300
|
+
import { readFileSync } from "fs";
|
|
1301
|
+
import { mkdir as mkdir2, writeFile as writeFile2, unlink as unlink2 } from "fs/promises";
|
|
1302
|
+
import { dirname } from "path";
|
|
1303
|
+
var ConfigStore = class {
|
|
1304
|
+
constructor(filePath) {
|
|
1305
|
+
this.filePath = filePath;
|
|
1306
|
+
}
|
|
1307
|
+
filePath;
|
|
1308
|
+
/**
|
|
1309
|
+
* Read the file synchronously. Returns `null` for any unreadable /
|
|
1310
|
+
* corrupt / schema-invalid file so the CLI degrades to defaults
|
|
1311
|
+
* instead of crashing on a stale on-disk format.
|
|
1312
|
+
*/
|
|
1313
|
+
loadSync() {
|
|
1314
|
+
let raw;
|
|
1315
|
+
try {
|
|
1316
|
+
raw = readFileSync(this.filePath, "utf8");
|
|
1317
|
+
} catch {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
let json;
|
|
1321
|
+
try {
|
|
1322
|
+
json = JSON.parse(raw);
|
|
1323
|
+
} catch {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
const parsed = CliConfigSchema.safeParse(json);
|
|
1327
|
+
return parsed.success ? parsed.data : null;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Persist the config. Creates the directory if missing. Mode 0644:
|
|
1331
|
+
* config is non-secret, unlike the session file.
|
|
1332
|
+
*/
|
|
1333
|
+
async save(config) {
|
|
1334
|
+
await mkdir2(dirname(this.filePath), { recursive: true });
|
|
1335
|
+
await writeFile2(this.filePath, JSON.stringify(config, null, 2) + "\n", {
|
|
1336
|
+
mode: 420
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Delete the config file. Idempotent — succeeds silently when the
|
|
1341
|
+
* file doesn't exist (the user's intent is "ensure no config",
|
|
1342
|
+
* not "the file definitely existed").
|
|
1343
|
+
*/
|
|
1344
|
+
async delete() {
|
|
1345
|
+
try {
|
|
1346
|
+
await unlink2(this.filePath);
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
throw err;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/** Path the file would be at, regardless of whether it exists. */
|
|
1355
|
+
get path() {
|
|
1356
|
+
return this.filePath;
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
// src/clients/BrowserOpener.ts
|
|
1361
|
+
import { spawn } from "child_process";
|
|
1362
|
+
var BrowserOpener = class {
|
|
1363
|
+
open(url) {
|
|
1364
|
+
const cmd = openerCommandForPlatform();
|
|
1365
|
+
try {
|
|
1366
|
+
const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
1367
|
+
child.unref();
|
|
1368
|
+
return true;
|
|
1369
|
+
} catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
function openerCommandForPlatform() {
|
|
1375
|
+
switch (process.platform) {
|
|
1376
|
+
case "darwin":
|
|
1377
|
+
return "open";
|
|
1378
|
+
case "win32":
|
|
1379
|
+
return "start";
|
|
1380
|
+
default:
|
|
1381
|
+
return "xdg-open";
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// src/clients/ChildProcessSpawner.ts
|
|
1386
|
+
import { spawn as spawn2 } from "child_process";
|
|
1387
|
+
var ChildProcessSpawner = class {
|
|
1388
|
+
/**
|
|
1389
|
+
* Run `command` with `args` and return the child's exit code. Pipes
|
|
1390
|
+
* stdout + stderr through `scrubber` before forwarding to the
|
|
1391
|
+
* parent process's streams.
|
|
1392
|
+
*/
|
|
1393
|
+
async run(args) {
|
|
1394
|
+
const child = spawn2(args.command, args.args, {
|
|
1395
|
+
env: args.env,
|
|
1396
|
+
cwd: args.cwd ?? process.cwd(),
|
|
1397
|
+
stdio: ["inherit", "pipe", "pipe"]
|
|
1398
|
+
});
|
|
1399
|
+
const stdoutClosed = pipeWithScrubbing(
|
|
1400
|
+
child.stdout,
|
|
1401
|
+
process.stdout,
|
|
1402
|
+
args.scrubber.stream(),
|
|
1403
|
+
args.onStdoutChunk
|
|
1404
|
+
);
|
|
1405
|
+
const stderrClosed = pipeWithScrubbing(
|
|
1406
|
+
child.stderr,
|
|
1407
|
+
process.stderr,
|
|
1408
|
+
args.scrubber.stream()
|
|
1409
|
+
);
|
|
1410
|
+
const forwardSignal = (signal) => {
|
|
1411
|
+
if (!child.killed) child.kill(signal);
|
|
1412
|
+
};
|
|
1413
|
+
process.on("SIGINT", forwardSignal);
|
|
1414
|
+
process.on("SIGTERM", forwardSignal);
|
|
1415
|
+
try {
|
|
1416
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
1417
|
+
child.once("exit", (code, signal) => {
|
|
1418
|
+
if (code !== null) resolve(code);
|
|
1419
|
+
else if (signal !== null) resolve(128 + signalNumber(signal));
|
|
1420
|
+
else resolve(1);
|
|
1421
|
+
});
|
|
1422
|
+
child.once("error", (err) => reject(err));
|
|
1423
|
+
});
|
|
1424
|
+
await Promise.all([stdoutClosed, stderrClosed]);
|
|
1425
|
+
return exitCode;
|
|
1426
|
+
} finally {
|
|
1427
|
+
process.removeListener("SIGINT", forwardSignal);
|
|
1428
|
+
process.removeListener("SIGTERM", forwardSignal);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
function pipeWithScrubbing(source, dest, scrubber, onChunk) {
|
|
1433
|
+
return new Promise((resolve) => {
|
|
1434
|
+
let flushed = false;
|
|
1435
|
+
const emit = (chunk) => {
|
|
1436
|
+
if (chunk.length === 0) return;
|
|
1437
|
+
dest.write(chunk);
|
|
1438
|
+
onChunk?.(chunk);
|
|
1439
|
+
};
|
|
1440
|
+
const finishOnce = () => {
|
|
1441
|
+
if (flushed) return;
|
|
1442
|
+
flushed = true;
|
|
1443
|
+
emit(scrubber.redact("", { final: true }));
|
|
1444
|
+
resolve();
|
|
1445
|
+
};
|
|
1446
|
+
source.on("end", finishOnce);
|
|
1447
|
+
source.on("close", finishOnce);
|
|
1448
|
+
source.on("error", () => {
|
|
1449
|
+
flushed = true;
|
|
1450
|
+
resolve();
|
|
1451
|
+
});
|
|
1452
|
+
source.setEncoding("utf8");
|
|
1453
|
+
source.on("data", (chunk) => {
|
|
1454
|
+
emit(scrubber.redact(chunk));
|
|
1455
|
+
});
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
function signalNumber(signal) {
|
|
1459
|
+
const map = {
|
|
1460
|
+
SIGHUP: 1,
|
|
1461
|
+
SIGINT: 2,
|
|
1462
|
+
SIGQUIT: 3,
|
|
1463
|
+
SIGKILL: 9,
|
|
1464
|
+
SIGTERM: 15
|
|
1465
|
+
};
|
|
1466
|
+
return map[signal] ?? 1;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/lib/paths.ts
|
|
1470
|
+
import { homedir } from "os";
|
|
1471
|
+
import { join } from "path";
|
|
1472
|
+
function configDir() {
|
|
1473
|
+
return process.env.GENI_CONFIG_DIR ?? join(homedir(), ".config", "geni");
|
|
1474
|
+
}
|
|
1475
|
+
function sessionFilePath() {
|
|
1476
|
+
return join(configDir(), "runner-session.json");
|
|
1477
|
+
}
|
|
1478
|
+
function configFilePath() {
|
|
1479
|
+
return join(configDir(), "config.json");
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/dependencyInjection/clients.ts
|
|
1483
|
+
var sessionStore = new SessionStore(sessionFilePath(), configDir());
|
|
1484
|
+
var configStore = new ConfigStore(configFilePath());
|
|
1485
|
+
var apiClientFactory = new ApiClientFactory();
|
|
1486
|
+
var browserOpener = new BrowserOpener();
|
|
1487
|
+
var childProcessSpawner = new ChildProcessSpawner();
|
|
1488
|
+
|
|
1489
|
+
// src/dependencyInjection/services.ts
|
|
1490
|
+
var configService = new ConfigService(configStore, sessionStore);
|
|
1491
|
+
var sessionContextService = new SessionContextService(
|
|
1492
|
+
sessionStore,
|
|
1493
|
+
apiClientFactory
|
|
1494
|
+
);
|
|
1495
|
+
var authService = new AuthService(
|
|
1496
|
+
apiClientFactory,
|
|
1497
|
+
sessionStore,
|
|
1498
|
+
browserOpener,
|
|
1499
|
+
configService
|
|
1500
|
+
);
|
|
1501
|
+
var workspaceService = new WorkspaceService(
|
|
1502
|
+
sessionContextService,
|
|
1503
|
+
sessionStore
|
|
1504
|
+
);
|
|
1505
|
+
var execService = new ExecService(
|
|
1506
|
+
sessionContextService,
|
|
1507
|
+
childProcessSpawner
|
|
1508
|
+
);
|
|
1509
|
+
var discoveryService = new DiscoveryService(
|
|
1510
|
+
sessionContextService,
|
|
1511
|
+
browserOpener,
|
|
1512
|
+
configService
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
// src/lib/cliErrors.ts
|
|
1516
|
+
function exitOnApiError(error, opts = {}) {
|
|
1517
|
+
if (error instanceof ApiError) {
|
|
1518
|
+
if (error.status === 404 && opts.notFoundMessage) {
|
|
1519
|
+
printError(opts.notFoundMessage);
|
|
1520
|
+
exit(ExitCode.NotFound);
|
|
1521
|
+
}
|
|
1522
|
+
if (error.status === 401) {
|
|
1523
|
+
printError(
|
|
1524
|
+
`Runner session is missing or expired: ${error.message} Run \`geni login\` to re-authenticate, then retry.`
|
|
1525
|
+
);
|
|
1526
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
1527
|
+
}
|
|
1528
|
+
if (error.status === 403) {
|
|
1529
|
+
printError(
|
|
1530
|
+
`Forbidden: ${error.message} Verify the resource id and the active workspace (\`geni workspace current\`).`
|
|
1531
|
+
);
|
|
1532
|
+
exit(ExitCode.Forbidden);
|
|
1533
|
+
}
|
|
1534
|
+
if (error.status === 404) {
|
|
1535
|
+
printError(
|
|
1536
|
+
`Not found: ${error.message} Verify the id exists in the active workspace.`
|
|
1537
|
+
);
|
|
1538
|
+
exit(ExitCode.NotFound);
|
|
1539
|
+
}
|
|
1540
|
+
if (error.status >= 500) {
|
|
1541
|
+
printError(
|
|
1542
|
+
`Server error (HTTP ${error.status}): ${error.message} Retry once before reporting.`
|
|
1543
|
+
);
|
|
1544
|
+
exit(ExitCode.InternalError);
|
|
1545
|
+
}
|
|
1546
|
+
printError(`Request failed (HTTP ${error.status}): ${error.message}`);
|
|
1547
|
+
exit(ExitCode.InternalError);
|
|
1548
|
+
}
|
|
1549
|
+
printError(
|
|
1550
|
+
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
|
1551
|
+
);
|
|
1552
|
+
exit(ExitCode.InternalError);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/commands/auth/login.ts
|
|
1556
|
+
function registerLogin(parent) {
|
|
1557
|
+
parent.command("login").description(
|
|
1558
|
+
"Authenticate via browser device-code flow. The CLI prints (and tries to open) a one-time approval URL; the operator picks a workspace in the browser; on approval the CLI saves a runner-session token to ~/.config/geni/runner-session.json. The token is bound to the URL it was minted against, so switching API URL after login requires logout + re-login."
|
|
1559
|
+
).option(
|
|
1560
|
+
"--server <url>",
|
|
1561
|
+
"Override the API base URL for this login. Precedence: this flag > $GENI_API_URL > `apiUrl` from `geni config` > https://cloud.generalinput.com. Whatever URL wins is locked into the session file."
|
|
1562
|
+
).option(
|
|
1563
|
+
"--workspace <slug>",
|
|
1564
|
+
"After approval, re-bind the session to this workspace slug instead of whatever the dashboard picker chose. Useful in CI / scripted setups where there is no human at the browser."
|
|
1565
|
+
).action(async (opts) => {
|
|
1566
|
+
try {
|
|
1567
|
+
await authService.login({
|
|
1568
|
+
server: opts.server,
|
|
1569
|
+
workspace: opts.workspace
|
|
1570
|
+
});
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
exitOnApiError(error);
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// src/commands/auth/logout.ts
|
|
1578
|
+
function registerLogout(parent) {
|
|
1579
|
+
parent.command("logout").description(
|
|
1580
|
+
"Revoke the runner-session token server-side and delete the local session file (~/.config/geni/runner-session.json). The local file is removed even if the server-side revoke fails. Running `geni logout` should never leave a token the operator thinks is gone still on disk."
|
|
1581
|
+
).action(async () => {
|
|
1582
|
+
try {
|
|
1583
|
+
await authService.logout();
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
exitOnApiError(error);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/commands/auth/status.ts
|
|
1591
|
+
async function executeAuthStatus(opts) {
|
|
1592
|
+
try {
|
|
1593
|
+
const status = await authService.status();
|
|
1594
|
+
if (!status) {
|
|
1595
|
+
if (opts.json) {
|
|
1596
|
+
printJson({ authenticated: false });
|
|
1597
|
+
} else {
|
|
1598
|
+
printError(
|
|
1599
|
+
"No runner session on disk. Run `geni login` to authenticate, then retry."
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
1603
|
+
}
|
|
1604
|
+
if (opts.json) {
|
|
1605
|
+
printJson(status);
|
|
1606
|
+
exit(ExitCode.Ok);
|
|
1607
|
+
}
|
|
1608
|
+
printSuccess(`Authenticated as ${status.user.email ?? status.user.id}`);
|
|
1609
|
+
printInfo(
|
|
1610
|
+
`Active workspace: ${status.workspace.slug} (${status.workspace.name}, ${status.workspace.role})`
|
|
1611
|
+
);
|
|
1612
|
+
exit(ExitCode.Ok);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
1615
|
+
printError(
|
|
1616
|
+
"Local session token was rejected by the server (revoked, expired, or pointed at a different server). Run `geni login` to mint a fresh session."
|
|
1617
|
+
);
|
|
1618
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
1619
|
+
}
|
|
1620
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1621
|
+
printError(
|
|
1622
|
+
`Failed to verify session: ${detail}. Check that the server is reachable; \`geni auth status --json\` shows the bound server URL.`
|
|
1623
|
+
);
|
|
1624
|
+
exit(ExitCode.InternalError);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
function registerStatus(parent) {
|
|
1628
|
+
parent.command("status").description(
|
|
1629
|
+
'Verify the active session and print operator + active workspace. Hits `/cli/auth/me` to confirm the token is still valid server-side; a stale local session (server revoked, expired) exits 78 with a clear "run geni login" message rather than reporting a fake-OK from the local file alone.'
|
|
1630
|
+
).option(
|
|
1631
|
+
"--json",
|
|
1632
|
+
"Emit a machine-readable JSON object: `{ authenticated, user, workspace, server }`. When unauthenticated, `{ authenticated: false }` is the only field."
|
|
1633
|
+
).action((opts) => executeAuthStatus(opts));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/commands/auth/index.ts
|
|
1637
|
+
function registerAuthCommands(program2) {
|
|
1638
|
+
registerLogin(program2);
|
|
1639
|
+
registerLogout(program2);
|
|
1640
|
+
const auth = program2.command("auth").description("Inspect the active CLI session.").action(() => executeAuthStatus({}));
|
|
1641
|
+
registerStatus(auth);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/lib/printTable.ts
|
|
1645
|
+
import chalk5 from "chalk";
|
|
1646
|
+
function printTable(headers, rows, opts = {}) {
|
|
1647
|
+
const out = opts.out ?? process.stdout;
|
|
1648
|
+
const colCount = headers.length;
|
|
1649
|
+
const markers = opts.markerFn ? rows.map((row, i) => opts.markerFn(row, i)) : null;
|
|
1650
|
+
const usesMarker = markers !== null && markers.some((m) => m) && !markers.every((m) => m);
|
|
1651
|
+
const widths = new Array(colCount).fill(0);
|
|
1652
|
+
for (let i = 0; i < colCount; i++) widths[i] = headers[i].length;
|
|
1653
|
+
for (const row of rows) {
|
|
1654
|
+
for (let i = 0; i < colCount; i++) {
|
|
1655
|
+
const len = row[i]?.length ?? 0;
|
|
1656
|
+
if (len > widths[i]) widths[i] = len;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const headerCells = headers.map(
|
|
1660
|
+
(h, i) => pad(chalk5.dim(h), h.length, widths[i], i === colCount - 1)
|
|
1661
|
+
);
|
|
1662
|
+
out.write((usesMarker ? " " : "") + headerCells.join(" ") + "\n");
|
|
1663
|
+
rows.forEach((row, rowIdx) => {
|
|
1664
|
+
const isMarked = usesMarker && markers[rowIdx] === true;
|
|
1665
|
+
const cells = row.map((raw, i) => {
|
|
1666
|
+
const value = raw ?? "";
|
|
1667
|
+
let colored = value;
|
|
1668
|
+
if (isMarked && i === 0) colored = chalk5.cyan(value);
|
|
1669
|
+
else if (opts.colorFn)
|
|
1670
|
+
colored = opts.colorFn(value, { row: rowIdx, col: i });
|
|
1671
|
+
return pad(colored, value.length, widths[i], i === colCount - 1);
|
|
1672
|
+
});
|
|
1673
|
+
const gutter = usesMarker ? `${isMarked ? chalk5.green("*") : " "} ` : "";
|
|
1674
|
+
out.write(gutter + cells.join(" ") + "\n");
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
function pad(colored, rawLen, width, isLast) {
|
|
1678
|
+
if (isLast) return colored;
|
|
1679
|
+
if (rawLen >= width) return colored;
|
|
1680
|
+
return colored + " ".repeat(width - rawLen);
|
|
1681
|
+
}
|
|
1682
|
+
function dimColumn(colIndex) {
|
|
1683
|
+
return (cell, args) => args.col === colIndex ? chalk5.dim(cell) : cell;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/commands/workspace/list.ts
|
|
1687
|
+
async function executeWorkspaceList(opts) {
|
|
1688
|
+
try {
|
|
1689
|
+
const { workspaces } = await workspaceService.list();
|
|
1690
|
+
if (opts.json) {
|
|
1691
|
+
printJson({
|
|
1692
|
+
active: workspaces.find((w) => w.isActive)?.slug ?? null,
|
|
1693
|
+
workspaces
|
|
1694
|
+
});
|
|
1695
|
+
exit(ExitCode.Ok);
|
|
1696
|
+
}
|
|
1697
|
+
if (workspaces.length === 0) {
|
|
1698
|
+
process.stdout.write(
|
|
1699
|
+
"No workspaces yet. Create or join one in the dashboard, then re-run.\n"
|
|
1700
|
+
);
|
|
1701
|
+
exit(ExitCode.Ok);
|
|
1702
|
+
}
|
|
1703
|
+
printTable(
|
|
1704
|
+
["SLUG", "NAME", "ROLE"],
|
|
1705
|
+
workspaces.map((w) => [w.slug, w.name, w.role]),
|
|
1706
|
+
{ markerFn: (_row, i) => workspaces[i].isActive }
|
|
1707
|
+
);
|
|
1708
|
+
exit(ExitCode.Ok);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
exitOnApiError(error);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function registerWorkspaceList(parent) {
|
|
1714
|
+
parent.command("list").description(
|
|
1715
|
+
"List every workspace the authenticated account belongs to. The active workspace is marked with a `*` and rendered in cyan. Server is the source of truth for both the list and the active marker."
|
|
1716
|
+
).option(
|
|
1717
|
+
"--json",
|
|
1718
|
+
"Emit `{ active: <slug>, workspaces: [...] }`. Each workspace carries `membershipId`, `organizationId`, `slug`, `name`, `role`, `isActive`."
|
|
1719
|
+
).action((opts) => executeWorkspaceList(opts));
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// src/commands/workspace/switch.ts
|
|
1723
|
+
import * as p from "@clack/prompts";
|
|
1724
|
+
function registerWorkspaceSwitch(parent) {
|
|
1725
|
+
parent.command("switch").argument(
|
|
1726
|
+
"[slug]",
|
|
1727
|
+
"Workspace slug to switch to. Omit for an interactive picker."
|
|
1728
|
+
).description(
|
|
1729
|
+
"Re-point the runner session at a different workspace the same account belongs to. The session token keeps working; only the active-workspace pointer changes server-side and in the local cache. Pass a slug for scriptable use, or omit for an interactive picker (TTY only)."
|
|
1730
|
+
).action(async (slug) => {
|
|
1731
|
+
try {
|
|
1732
|
+
const { session } = await sessionContextService.requireAuthed();
|
|
1733
|
+
const { workspaces } = await workspaceService.list();
|
|
1734
|
+
const target = slug ? findBySlug(workspaces, slug) : await pickInteractively({
|
|
1735
|
+
workspaces,
|
|
1736
|
+
currentMembershipId: session.workspace.membershipId
|
|
1737
|
+
});
|
|
1738
|
+
if (!target) return;
|
|
1739
|
+
if (target.membershipId === session.workspace.membershipId) {
|
|
1740
|
+
printInfo(`Already on ${target.slug} (${target.name}). No change.`);
|
|
1741
|
+
exit(ExitCode.Ok);
|
|
1742
|
+
}
|
|
1743
|
+
const me = await workspaceService.switch({
|
|
1744
|
+
membershipId: target.membershipId
|
|
1745
|
+
});
|
|
1746
|
+
printSuccess(
|
|
1747
|
+
`Active workspace: ${me.workspace.slug} (${me.workspace.name})`
|
|
1748
|
+
);
|
|
1749
|
+
exit(ExitCode.Ok);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
exitOnApiError(error);
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
function findBySlug(workspaces, slug) {
|
|
1756
|
+
const target = workspaces.find((w) => w.slug === slug);
|
|
1757
|
+
if (!target) {
|
|
1758
|
+
const available = workspaces.map((w) => w.slug).join(", ") || "none";
|
|
1759
|
+
printError(
|
|
1760
|
+
`No workspace with slug "${slug}" on this account. Available: [${available}]. Pick one of those, or run \`geni workspace list\` for the full set with names + roles.`
|
|
1761
|
+
);
|
|
1762
|
+
exit(ExitCode.NotFound);
|
|
1763
|
+
}
|
|
1764
|
+
return target;
|
|
1765
|
+
}
|
|
1766
|
+
async function pickInteractively(args) {
|
|
1767
|
+
if (!process.stdin.isTTY) {
|
|
1768
|
+
printError(
|
|
1769
|
+
"Interactive picker needs a TTY. Pass a slug: `geni workspace switch <slug>`."
|
|
1770
|
+
);
|
|
1771
|
+
exit(ExitCode.GenericError);
|
|
1772
|
+
}
|
|
1773
|
+
if (args.workspaces.length === 0) {
|
|
1774
|
+
printError(
|
|
1775
|
+
"No workspaces on this account. Create or join one in the dashboard before running `geni workspace switch`."
|
|
1776
|
+
);
|
|
1777
|
+
exit(ExitCode.NotFound);
|
|
1778
|
+
}
|
|
1779
|
+
if (args.workspaces.length === 1) {
|
|
1780
|
+
printInfo("You only have one workspace; nothing to switch to.");
|
|
1781
|
+
exit(ExitCode.Ok);
|
|
1782
|
+
}
|
|
1783
|
+
const choice = await p.select({
|
|
1784
|
+
message: "Pick a workspace",
|
|
1785
|
+
initialValue: args.currentMembershipId,
|
|
1786
|
+
options: args.workspaces.map((w) => ({
|
|
1787
|
+
value: w.membershipId,
|
|
1788
|
+
label: `${w.slug} (${w.name})`,
|
|
1789
|
+
hint: w.role
|
|
1790
|
+
}))
|
|
1791
|
+
});
|
|
1792
|
+
if (p.isCancel(choice)) {
|
|
1793
|
+
printInfo("Cancelled.");
|
|
1794
|
+
return void 0;
|
|
1795
|
+
}
|
|
1796
|
+
return args.workspaces.find((w) => w.membershipId === choice);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// src/commands/workspace/current.ts
|
|
1800
|
+
function registerWorkspaceCurrent(parent) {
|
|
1801
|
+
parent.command("current").description(
|
|
1802
|
+
"Print the active workspace's slug. Reads the local session file directly (no network round-trip), so it's safe to use in shell substitutions like `cd ~/repos/$(geni workspace current)`. Use `--verbose` for slug + name + role + id, or `--json` for machine-readable."
|
|
1803
|
+
).option("--verbose", "Include name, role, and id alongside the slug.").option(
|
|
1804
|
+
"--json",
|
|
1805
|
+
"Emit the workspace record: `{ membershipId, organizationId, slug, name, role }`."
|
|
1806
|
+
).action((opts) => {
|
|
1807
|
+
void run(opts);
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
async function run(opts) {
|
|
1811
|
+
const current = await workspaceService.current();
|
|
1812
|
+
if (!current) {
|
|
1813
|
+
if (opts.json) {
|
|
1814
|
+
printJson({ authenticated: false });
|
|
1815
|
+
} else {
|
|
1816
|
+
printError(
|
|
1817
|
+
"No runner session on disk. Run `geni login` to authenticate, then retry."
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
exit(ExitCode.SessionMissingOrExpired);
|
|
1821
|
+
}
|
|
1822
|
+
const ws = current.workspace;
|
|
1823
|
+
if (opts.json) {
|
|
1824
|
+
printJson(ws);
|
|
1825
|
+
exit(ExitCode.Ok);
|
|
1826
|
+
}
|
|
1827
|
+
if (opts.verbose) {
|
|
1828
|
+
process.stdout.write(`slug: ${ws.slug}
|
|
1829
|
+
`);
|
|
1830
|
+
process.stdout.write(`name: ${ws.name}
|
|
1831
|
+
`);
|
|
1832
|
+
process.stdout.write(`role: ${ws.role}
|
|
1833
|
+
`);
|
|
1834
|
+
process.stdout.write(`id: ${ws.organizationId}
|
|
1835
|
+
`);
|
|
1836
|
+
exit(ExitCode.Ok);
|
|
1837
|
+
}
|
|
1838
|
+
process.stdout.write(`${ws.slug}
|
|
1839
|
+
`);
|
|
1840
|
+
exit(ExitCode.Ok);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// src/commands/workspace/index.ts
|
|
1844
|
+
function registerWorkspaceCommands(program2) {
|
|
1845
|
+
const workspace = program2.command("workspace").alias("workspaces").description(
|
|
1846
|
+
"List the workspaces the operator's account belongs to and switch which one this CLI session targets. The runner-session token is account-scoped; the active workspace pointer determines which org's credentials, integrations, and audit logs every other command operates on."
|
|
1847
|
+
).action(() => executeWorkspaceList({}));
|
|
1848
|
+
registerWorkspaceList(workspace);
|
|
1849
|
+
registerWorkspaceSwitch(workspace);
|
|
1850
|
+
registerWorkspaceCurrent(workspace);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/commands/exec/bash.ts
|
|
1854
|
+
function registerExecBash(parent) {
|
|
1855
|
+
parent.command("bash").description(
|
|
1856
|
+
"Run `bash -lc <cmd>` locally with cloud-resolved credentials injected as env vars. Output is streamed back through a scrubber that replaces every registered secret with [REDACTED:credential_<id>]."
|
|
1857
|
+
).option(
|
|
1858
|
+
"--cred <id>",
|
|
1859
|
+
"Credential id to inject. Repeat once per credential; each --cred MUST be followed by exactly one --reason. Pairing is by order: the Nth --cred goes with the Nth --reason. Discover ids via `geni credential list --service <service>`.",
|
|
1860
|
+
collect,
|
|
1861
|
+
[]
|
|
1862
|
+
).option(
|
|
1863
|
+
"--reason <text>",
|
|
1864
|
+
"Why this credential is being accessed. Lands in the credential access log and is shown to the operator. Re-state on every call; the audit log is per-invocation.",
|
|
1865
|
+
collect,
|
|
1866
|
+
[]
|
|
1867
|
+
).option(
|
|
1868
|
+
"--cwd <path>",
|
|
1869
|
+
"Working directory for the command. Defaults to your current shell cwd."
|
|
1870
|
+
).option(
|
|
1871
|
+
"--quiet",
|
|
1872
|
+
"Suppress geni's `resolved <cred> \u2192 ...` status lines on stderr. Subprocess output still passes through, scrubbed."
|
|
1873
|
+
).allowExcessArguments(true).action(async (opts, command) => {
|
|
1874
|
+
try {
|
|
1875
|
+
const code = await runExecBash(opts, command.args);
|
|
1876
|
+
process.exit(code);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
exitOnApiError(error);
|
|
1879
|
+
}
|
|
1880
|
+
}).addHelpText(
|
|
1881
|
+
"after",
|
|
1882
|
+
`
|
|
1883
|
+
Always-injected env vars (no --cred required):
|
|
1884
|
+
$PLATFORM_API_KEY short-lived bearer for $PLATFORM_BASE_URL/<service> calls
|
|
1885
|
+
$PLATFORM_BASE_URL the cloud's base URL
|
|
1886
|
+
|
|
1887
|
+
Per-credential env vars are derived from the integration's secret
|
|
1888
|
+
schema, with the credential id as a suffix so two credentials of the
|
|
1889
|
+
same service can coexist: $<SERVICE>_<FIELD>_<id>
|
|
1890
|
+
Look up the exact names before constructing the command:
|
|
1891
|
+
geni credential get <id> --field envVars # for a known cred
|
|
1892
|
+
geni integration get <service> --field envVars # for a service
|
|
1893
|
+
|
|
1894
|
+
Examples:
|
|
1895
|
+
|
|
1896
|
+
# One credential, simple curl:
|
|
1897
|
+
geni exec bash \\
|
|
1898
|
+
--cred cred_01HX --reason "Listing Slack channels" \\
|
|
1899
|
+
-- 'curl -s -H "Authorization: Bearer $SLACK_ACCESS_TOKEN_01HX" https://slack.com/api/conversations.list'
|
|
1900
|
+
|
|
1901
|
+
# Multiple credentials, fan-out (suffix keeps them distinct):
|
|
1902
|
+
geni exec bash \\
|
|
1903
|
+
--cred cred_slackA --reason "Posting to #engineering" \\
|
|
1904
|
+
--cred cred_slackB --reason "Posting to #marketing" \\
|
|
1905
|
+
-- 'curl ... $SLACK_ACCESS_TOKEN_SLACKA ... && curl ... $SLACK_ACCESS_TOKEN_SLACKB ...'
|
|
1906
|
+
|
|
1907
|
+
# No --cred. Platform service:
|
|
1908
|
+
geni exec bash -- 'curl -s -H "Authorization: Bearer $PLATFORM_API_KEY" "$PLATFORM_BASE_URL/v1/web-search" -d ...'
|
|
1909
|
+
|
|
1910
|
+
Exit codes:
|
|
1911
|
+
0\u2013125 subprocess's own exit code
|
|
1912
|
+
77 server refused to resolve a credential, don't retry
|
|
1913
|
+
78 runner session missing or expired, run \`geni login\`
|
|
1914
|
+
125 internal CLI error
|
|
1915
|
+
`
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
function collect(value, prev) {
|
|
1919
|
+
return [...prev, value];
|
|
1920
|
+
}
|
|
1921
|
+
async function runExecBash(opts, positional) {
|
|
1922
|
+
const command = positional.join(" ").trim();
|
|
1923
|
+
if (!command) {
|
|
1924
|
+
printError(
|
|
1925
|
+
"Missing the bash command. Put it after a literal `--` (flags before `--` belong to geni). Example: `geni exec bash --cred cred_X --reason \"...\" -- 'curl ...'`."
|
|
1926
|
+
);
|
|
1927
|
+
exit(ExitCode.InvalidArgs);
|
|
1928
|
+
}
|
|
1929
|
+
if (opts.cred.length !== opts.reason.length) {
|
|
1930
|
+
printError(
|
|
1931
|
+
`--cred and --reason must be paired one-to-one (got ${opts.cred.length} --cred and ${opts.reason.length} --reason). Example: \`--cred cred_A --reason "..." --cred cred_B --reason "..."\`.`
|
|
1932
|
+
);
|
|
1933
|
+
exit(ExitCode.InvalidArgs);
|
|
1934
|
+
}
|
|
1935
|
+
return execService.runBash({
|
|
1936
|
+
command,
|
|
1937
|
+
// Pair-by-index: Commander's `collect` reducer keeps both arrays
|
|
1938
|
+
// in declaration order, so opts.cred[i] always corresponds to
|
|
1939
|
+
// opts.reason[i].
|
|
1940
|
+
credentials: opts.cred.map((id, i) => ({
|
|
1941
|
+
id,
|
|
1942
|
+
reason: opts.reason[i]
|
|
1943
|
+
})),
|
|
1944
|
+
cwd: opts.cwd,
|
|
1945
|
+
quiet: opts.quiet
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// src/commands/exec/index.ts
|
|
1950
|
+
function registerExecCommands(program2) {
|
|
1951
|
+
const exec = program2.command("exec").description(
|
|
1952
|
+
"Run bash locally with the operator's credentials resolved by the cloud and injected as env vars. Plaintext secrets never enter the agent's transcript \u2014 output is streamed through a scrubber that redacts every registered secret value."
|
|
1953
|
+
);
|
|
1954
|
+
registerExecBash(exec);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// src/commands/credential/list.ts
|
|
1958
|
+
async function executeCredentialList(opts) {
|
|
1959
|
+
try {
|
|
1960
|
+
const credentials = await discoveryService.listCredentials({
|
|
1961
|
+
service: opts.service,
|
|
1962
|
+
mine: opts.mine,
|
|
1963
|
+
query: opts.query
|
|
1964
|
+
});
|
|
1965
|
+
if (opts.json) {
|
|
1966
|
+
printJson({ credentials });
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (credentials.length === 0) {
|
|
1970
|
+
const filterDesc = describeCredentialFilters(opts);
|
|
1971
|
+
process.stdout.write(
|
|
1972
|
+
filterDesc ? `No credentials match (${filterDesc}). Drop filters or run \`geni credential connect <service>\` to add one.
|
|
1973
|
+
` : "No credentials connected yet. Connect one with `geni credential connect <service>` (find a service slug with `geni integration list -q <keyword>`).\n"
|
|
1974
|
+
);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
printTable(
|
|
1978
|
+
["ID", "SERVICE", "TITLE"],
|
|
1979
|
+
credentials.map((c) => [c.id, c.service, c.title]),
|
|
1980
|
+
{
|
|
1981
|
+
// `*` flags the rows you own (auto-suppressed if every row is
|
|
1982
|
+
// yours or none are — i.e. the marker only renders when it
|
|
1983
|
+
// actually distinguishes a subset).
|
|
1984
|
+
markerFn: (_row, i) => credentials[i].isOwnedByViewer,
|
|
1985
|
+
colorFn: dimColumn(0)
|
|
1986
|
+
}
|
|
1987
|
+
);
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
exitOnApiError(error);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
function registerCredentialList(parent) {
|
|
1993
|
+
parent.command("list").description(
|
|
1994
|
+
"List credentials the runner session can use (owned, org-shared, or per-credential collaborator). Default columns are id / service / title \u2014 for env var names, OAuth scopes, or the full record use `geni credential get <id>` (or `--field <path>` / `--json`)."
|
|
1995
|
+
).option(
|
|
1996
|
+
"--service <slug>",
|
|
1997
|
+
"Filter to one service (e.g. slack, github, salesforce). Use `geni integration list` to discover slugs."
|
|
1998
|
+
).option(
|
|
1999
|
+
"--mine",
|
|
2000
|
+
"Only credentials owned by you. Excludes credentials shared with you via org-grant or collaborator rows."
|
|
2001
|
+
).option(
|
|
2002
|
+
"-q, --query <text>",
|
|
2003
|
+
"Substring rank across service, title, and provider name. Service slug weighs highest."
|
|
2004
|
+
).option(
|
|
2005
|
+
"--json",
|
|
2006
|
+
"Machine-readable output. Each entry carries `id`, `service`, `envVars`, `grantedScopes`, etc."
|
|
2007
|
+
).action((opts) => executeCredentialList(opts));
|
|
2008
|
+
}
|
|
2009
|
+
function describeCredentialFilters(opts) {
|
|
2010
|
+
const parts = [];
|
|
2011
|
+
if (opts.service) parts.push(`--service ${opts.service}`);
|
|
2012
|
+
if (opts.mine) parts.push("--mine");
|
|
2013
|
+
if (opts.query) parts.push(`-q "${opts.query}"`);
|
|
2014
|
+
return parts.join(" ");
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// src/lib/jsonField.ts
|
|
2018
|
+
function extractField(value, path) {
|
|
2019
|
+
const segments = path.split(".").filter((s) => s.length > 0);
|
|
2020
|
+
let current = value;
|
|
2021
|
+
for (const segment of segments) {
|
|
2022
|
+
if (current === null || current === void 0) return void 0;
|
|
2023
|
+
if (Array.isArray(current)) {
|
|
2024
|
+
const idx = Number.parseInt(segment, 10);
|
|
2025
|
+
if (Number.isNaN(idx)) return void 0;
|
|
2026
|
+
current = current[idx];
|
|
2027
|
+
} else if (typeof current === "object") {
|
|
2028
|
+
current = Reflect.get(current, segment);
|
|
2029
|
+
} else {
|
|
2030
|
+
return void 0;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
return current;
|
|
2034
|
+
}
|
|
2035
|
+
function formatExtractedField(value) {
|
|
2036
|
+
if (typeof value === "string") return value;
|
|
2037
|
+
if (value === null || value === void 0) return "";
|
|
2038
|
+
return JSON.stringify(value, null, 2);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// src/commands/credential/get.ts
|
|
2042
|
+
function registerCredentialGet(parent) {
|
|
2043
|
+
parent.command("get").argument("<id>", "Credential id (e.g. `cred_01HX\u2026`).").description(
|
|
2044
|
+
"Print one credential's full record: env var names, per-field secret/non-secret breakdown, ownership, sharing, scopes. No plaintext secret values are returned. Those resolve server-side at `geni exec bash` time."
|
|
2045
|
+
).option("--json", "Machine-readable output (full record).").option(
|
|
2046
|
+
"--field <path>",
|
|
2047
|
+
"Print one dotted-path field instead of the whole record. Examples: `envVars`, `grantedScopes`, `service`, `isShared`."
|
|
2048
|
+
).action(async (id, opts) => {
|
|
2049
|
+
try {
|
|
2050
|
+
const detail = await discoveryService.getCredential(id);
|
|
2051
|
+
if (opts.field) {
|
|
2052
|
+
const value = extractField(detail, opts.field);
|
|
2053
|
+
if (value === void 0) {
|
|
2054
|
+
const topLevel = Object.keys(detail).join(", ");
|
|
2055
|
+
printError(
|
|
2056
|
+
`Field "${opts.field}" is not on the credential record. Top-level fields: [${topLevel}]. Pass a dotted path that exists, or run \`geni credential get ${id} --json\` to see the whole shape.`
|
|
2057
|
+
);
|
|
2058
|
+
exit(ExitCode.NotFound);
|
|
2059
|
+
}
|
|
2060
|
+
process.stdout.write(formatExtractedField(value) + "\n");
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
if (opts.json) {
|
|
2064
|
+
printJson(detail);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
printDefault(detail);
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
exitOnApiError(error, {
|
|
2070
|
+
notFoundMessage: `No credential with id "${id}" in the active workspace. Run \`geni credential list\` to find the right id.`
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
function printDefault(detail) {
|
|
2076
|
+
const out = process.stdout;
|
|
2077
|
+
out.write(`${detail.id} ${detail.title}
|
|
2078
|
+
`);
|
|
2079
|
+
out.write(`provider: ${detail.providerTitle}
|
|
2080
|
+
`);
|
|
2081
|
+
out.write(`type: ${detail.credentialType}
|
|
2082
|
+
`);
|
|
2083
|
+
out.write(`created: ${detail.createdAt.slice(0, 10)}
|
|
2084
|
+
`);
|
|
2085
|
+
out.write(
|
|
2086
|
+
`ownership: ${detail.isOwnedByViewer ? "owned by you" : "shared with you"}
|
|
2087
|
+
`
|
|
2088
|
+
);
|
|
2089
|
+
out.write(`shared: ${detail.isShared ? "yes" : "no"}
|
|
2090
|
+
|
|
2091
|
+
`);
|
|
2092
|
+
out.write("envVars:\n");
|
|
2093
|
+
for (const v of detail.envVars) out.write(` ${v}
|
|
2094
|
+
`);
|
|
2095
|
+
out.write("\n");
|
|
2096
|
+
if (detail.fields.length > 0) {
|
|
2097
|
+
out.write("fields:\n");
|
|
2098
|
+
for (const f of detail.fields) {
|
|
2099
|
+
out.write(` ${f.name.padEnd(14)} ${f.isSecret ? "secret" : "config"}
|
|
2100
|
+
`);
|
|
2101
|
+
}
|
|
2102
|
+
out.write("\n");
|
|
2103
|
+
}
|
|
2104
|
+
if (detail.grantedScopes && detail.grantedScopes.length > 0) {
|
|
2105
|
+
out.write(`scopes: ${detail.grantedScopes.join(", ")}
|
|
2106
|
+
`);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// src/commands/credential/connect.ts
|
|
2111
|
+
import chalk6 from "chalk";
|
|
2112
|
+
function registerCredentialConnect(parent) {
|
|
2113
|
+
parent.command("connect").argument(
|
|
2114
|
+
"<service>",
|
|
2115
|
+
"Service slug (slack, github, \u2026). Use `geni integration list` to discover."
|
|
2116
|
+
).description(
|
|
2117
|
+
"Open the dashboard's authorize page for a service so the operator can connect a new credential. Lightweight by design: no polling, no rendezvous. After the operator finishes in the dashboard, re-run `geni credential list --service <service>` to discover the new credential id."
|
|
2118
|
+
).option(
|
|
2119
|
+
"--print-url",
|
|
2120
|
+
"Don't auto-open the browser; print the URL to stdout. Useful in headless / SSH / CI sessions."
|
|
2121
|
+
).option("--no-browser", "Alias for --print-url.").action(async (service, opts) => {
|
|
2122
|
+
try {
|
|
2123
|
+
const intent = await discoveryService.connectCredential({
|
|
2124
|
+
service,
|
|
2125
|
+
// Commander turns `--no-browser` into `noBrowser: false`,
|
|
2126
|
+
// so the print-only branch is "either flag was passed".
|
|
2127
|
+
printUrlOnly: opts.printUrl || opts.noBrowser === false
|
|
2128
|
+
});
|
|
2129
|
+
if (intent.kind === "print-url") {
|
|
2130
|
+
process.stdout.write(`${intent.url}
|
|
2131
|
+
`);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
printInfo(`Opening ${chalk6.cyan(intent.url)}`);
|
|
2135
|
+
printInfo("\u21B3 approve in your browser");
|
|
2136
|
+
process.stdout.write(
|
|
2137
|
+
`
|
|
2138
|
+
When you're done, re-run: geni credential list --service ${service}
|
|
2139
|
+
`
|
|
2140
|
+
);
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
exitOnApiError(error, {
|
|
2143
|
+
notFoundMessage: `Service "${service}" not found.`
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// src/commands/credential/index.ts
|
|
2150
|
+
function registerCredentialCommands(program2) {
|
|
2151
|
+
const credential = program2.command("credential").alias("credentials").description(
|
|
2152
|
+
"Discover the operator's connected credentials and prompt them to connect new ones. Discovery-only: the agent sees credential ids and the env var names that get set when each is declared on `geni exec bash`, never plaintext secrets. Resolution and decryption happen server-side at exec time."
|
|
2153
|
+
).action(() => executeCredentialList({}));
|
|
2154
|
+
registerCredentialList(credential);
|
|
2155
|
+
registerCredentialGet(credential);
|
|
2156
|
+
registerCredentialConnect(credential);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// src/commands/integration/list.ts
|
|
2160
|
+
import chalk7 from "chalk";
|
|
2161
|
+
var VALID_TYPES = ["oauth2", "apiKey", "platform", "noAuth"];
|
|
2162
|
+
var VALID_TYPE_SET = new Set(VALID_TYPES);
|
|
2163
|
+
async function executeIntegrationList(opts) {
|
|
2164
|
+
if (opts.type && !VALID_TYPE_SET.has(opts.type)) {
|
|
2165
|
+
printError(
|
|
2166
|
+
`Invalid --type "${opts.type}". Valid values: [${VALID_TYPES.join(", ")}]. \`oauth2\` and \`apiKey\` are user-connected credentials; \`platform\` is first-party (uses $PLATFORM_API_KEY); \`noAuth\` is a public API.`
|
|
2167
|
+
);
|
|
2168
|
+
exit(ExitCode.InvalidArgs);
|
|
2169
|
+
}
|
|
2170
|
+
try {
|
|
2171
|
+
const integrations = await discoveryService.listIntegrations({
|
|
2172
|
+
type: opts.type,
|
|
2173
|
+
query: opts.query
|
|
2174
|
+
});
|
|
2175
|
+
if (opts.json) {
|
|
2176
|
+
printJson({ integrations });
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
if (integrations.length === 0) {
|
|
2180
|
+
const filterDesc = [
|
|
2181
|
+
opts.type ? `--type ${opts.type}` : null,
|
|
2182
|
+
opts.query ? `-q "${opts.query}"` : null
|
|
2183
|
+
].filter(Boolean).join(" ");
|
|
2184
|
+
process.stdout.write(
|
|
2185
|
+
`No integrations match (${filterDesc || "no filters"}). Drop filters or broaden the query.
|
|
2186
|
+
`
|
|
2187
|
+
);
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
printTable(
|
|
2191
|
+
["SERVICE", "TITLE", "TYPE"],
|
|
2192
|
+
integrations.map((i) => [i.service, i.title, i.credentialType]),
|
|
2193
|
+
{
|
|
2194
|
+
// Service slug is the lookup key for every other CLI verb
|
|
2195
|
+
// (`credential connect <service>`, `integration get <service>`),
|
|
2196
|
+
// so render it cyan to draw the eye. Type is metadata — dim.
|
|
2197
|
+
colorFn: (cell, args) => {
|
|
2198
|
+
if (args.col === 0) return chalk7.cyan(cell);
|
|
2199
|
+
if (args.col === 2) return chalk7.dim(cell);
|
|
2200
|
+
return cell;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
);
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
exitOnApiError(error);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
function registerIntegrationList(parent) {
|
|
2209
|
+
parent.command("list").description(
|
|
2210
|
+
"List every integration available to the operator's organization: third-party services (slack, github, salesforce), platform services (chat-completion, search-internet), and noAuth APIs. The starting point for finding the canonical `service` slug to pass to `geni credential connect` or to filter `geni credential list`."
|
|
2211
|
+
).option(
|
|
2212
|
+
"--type <kind>",
|
|
2213
|
+
`Filter by credential type: ${VALID_TYPES.join(" | ")}. \`oauth2\` and \`apiKey\` need a connected credential; \`platform\` uses $PLATFORM_API_KEY; \`noAuth\` is a public API that needs no auth.`
|
|
2214
|
+
).option(
|
|
2215
|
+
"-q, --query <text>",
|
|
2216
|
+
'Server-side hybrid (semantic + lexical) search across service slug, title, and description. Search by capability ("send message", "calendar"), not just service name.'
|
|
2217
|
+
).option(
|
|
2218
|
+
"--json",
|
|
2219
|
+
"Machine-readable output. Each entry is `{ service, title, description, credentialType }`."
|
|
2220
|
+
).action((opts) => executeIntegrationList(opts));
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// src/commands/integration/get.ts
|
|
2224
|
+
function registerIntegrationGet(parent) {
|
|
2225
|
+
parent.command("get").argument("<service>", "Service slug (slack, github, \u2026).").description(
|
|
2226
|
+
"Full setup metadata for one integration: bash env var names that get set when its credentials are declared, per-field secret/non-secret breakdown, OAuth scope catalog (when applicable), operator-facing setup guide. Read this when the agent needs to walk the operator through credential setup or wants to know exactly what env vars a credential will produce before declaring one."
|
|
2227
|
+
).option("--json", "Machine-readable output (full record).").option(
|
|
2228
|
+
"--field <path>",
|
|
2229
|
+
"Print one dotted-path field instead of the whole record. Examples: `envVars`, `oauthScopes`, `fields`, `setupGuide`."
|
|
2230
|
+
).action(async (service, opts) => {
|
|
2231
|
+
try {
|
|
2232
|
+
const detail = await discoveryService.getIntegration(service);
|
|
2233
|
+
if (opts.field) {
|
|
2234
|
+
const value = extractField(detail, opts.field);
|
|
2235
|
+
if (value === void 0) {
|
|
2236
|
+
const topLevel = Object.keys(detail).join(", ");
|
|
2237
|
+
printError(
|
|
2238
|
+
`Field "${opts.field}" is not on the integration record. Top-level fields: [${topLevel}]. Pass a dotted path that exists, or run \`geni integration get ${service} --json\` to see the whole shape.`
|
|
2239
|
+
);
|
|
2240
|
+
exit(ExitCode.NotFound);
|
|
2241
|
+
}
|
|
2242
|
+
process.stdout.write(formatExtractedField(value) + "\n");
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (opts.json) {
|
|
2246
|
+
printJson(detail);
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
printDefault2(detail);
|
|
2250
|
+
} catch (error) {
|
|
2251
|
+
exitOnApiError(error, {
|
|
2252
|
+
notFoundMessage: `No integration with slug "${service}". Run \`geni integration list -q <keyword>\` to find the right slug (slugs are the service's brand name lowercased, e.g. \`slack\`).`
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
function printDefault2(detail) {
|
|
2258
|
+
const out = process.stdout;
|
|
2259
|
+
out.write(`${detail.service} ${detail.title}
|
|
2260
|
+
`);
|
|
2261
|
+
out.write(`type: ${detail.credentialType}
|
|
2262
|
+
|
|
2263
|
+
`);
|
|
2264
|
+
out.write("description:\n");
|
|
2265
|
+
out.write(` ${detail.description}
|
|
2266
|
+
|
|
2267
|
+
`);
|
|
2268
|
+
if (detail.oauthScopes && detail.oauthScopes.length > 0) {
|
|
2269
|
+
out.write("OAuth scopes:\n");
|
|
2270
|
+
for (const scope of detail.oauthScopes) {
|
|
2271
|
+
out.write(
|
|
2272
|
+
` ${scope.name.padEnd(40)} ${scope.description || "(no description)"}
|
|
2273
|
+
`
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
out.write("\n");
|
|
2277
|
+
}
|
|
2278
|
+
if (detail.fields.length > 0) {
|
|
2279
|
+
out.write("Secret schema:\n");
|
|
2280
|
+
for (const f of detail.fields) {
|
|
2281
|
+
out.write(` ${f.name.padEnd(20)} ${f.isSecret ? "secret" : "config"}
|
|
2282
|
+
`);
|
|
2283
|
+
}
|
|
2284
|
+
out.write("\n");
|
|
2285
|
+
}
|
|
2286
|
+
if (detail.envVars.length > 0) {
|
|
2287
|
+
out.write("Bash env vars set when used:\n");
|
|
2288
|
+
out.write(` ${detail.envVars.map((v) => `$${v}`).join(", ")}
|
|
2289
|
+
`);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// src/commands/integration/operations.ts
|
|
2294
|
+
function registerIntegrationOperations(parent) {
|
|
2295
|
+
parent.command("operations").argument("<service>", "Service slug (e.g. slack, github, stripe).").description(
|
|
2296
|
+
"List the operations one integration exposes (e.g. for slack: 'Send a Message', 'Get Channel History'). Each row's id is the input to `geni integration operation <id>` for full reference docs."
|
|
2297
|
+
).option(
|
|
2298
|
+
"-q, --query <text>",
|
|
2299
|
+
"Substring rank across operation title and description. Title weighs higher."
|
|
2300
|
+
).option(
|
|
2301
|
+
"--json",
|
|
2302
|
+
"Machine-readable output. Each entry is `{ id, title, description }`."
|
|
2303
|
+
).action(async (service, opts) => {
|
|
2304
|
+
try {
|
|
2305
|
+
const operations = await discoveryService.listOperations({
|
|
2306
|
+
service,
|
|
2307
|
+
query: opts.query
|
|
2308
|
+
});
|
|
2309
|
+
if (opts.json) {
|
|
2310
|
+
printJson({ operations });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (operations.length === 0) {
|
|
2314
|
+
process.stdout.write(
|
|
2315
|
+
opts.query ? `No operations on \`${service}\` match -q "${opts.query}". Drop the query to list all, or broaden the keyword.
|
|
2316
|
+
` : `\`${service}\` exposes no operations. Verify the service slug with \`geni integration list\`.
|
|
2317
|
+
`
|
|
2318
|
+
);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
printTable(
|
|
2322
|
+
["ID", "TITLE", "DESCRIPTION"],
|
|
2323
|
+
operations.map((op) => [op.id, op.title, op.description]),
|
|
2324
|
+
{ colorFn: dimColumn(0) }
|
|
2325
|
+
);
|
|
2326
|
+
} catch (error) {
|
|
2327
|
+
exitOnApiError(error, {
|
|
2328
|
+
notFoundMessage: `No integration with slug "${service}". Run \`geni integration list -q <keyword>\` to find the right slug.`
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// src/commands/integration/operation.ts
|
|
2335
|
+
var VALID_FORMATS = ["text", "markdown", "json"];
|
|
2336
|
+
var VALID_FORMAT_SET = new Set(VALID_FORMATS);
|
|
2337
|
+
function registerIntegrationOperation(parent) {
|
|
2338
|
+
parent.command("operation").argument(
|
|
2339
|
+
"<serviceOrId>",
|
|
2340
|
+
"Operation id, or service slug followed by operation id."
|
|
2341
|
+
).argument("[opId]", "Operation id when the first arg is a service slug.").description(
|
|
2342
|
+
"Full reference for one operation: HTTP method+path, required scopes, params, response shape, code example, and the env var names that get set when a credential of this service is declared on `geni exec bash`. The single most important command before constructing a credentialed request. Read this, then write the call."
|
|
2343
|
+
).option(
|
|
2344
|
+
"--format <fmt>",
|
|
2345
|
+
`Output format: ${VALID_FORMATS.join(" | ")}. \`markdown\` is the cleanest paste into an LLM context window.`,
|
|
2346
|
+
"text"
|
|
2347
|
+
).action(
|
|
2348
|
+
async (serviceOrId, maybeOpId, opts) => {
|
|
2349
|
+
const format = opts.format ?? "text";
|
|
2350
|
+
if (!VALID_FORMAT_SET.has(format)) {
|
|
2351
|
+
printError(
|
|
2352
|
+
`Invalid --format "${format}". Valid values: [${VALID_FORMATS.join(", ")}]. Default is \`text\`; use \`markdown\` when pasting into an LLM context.`
|
|
2353
|
+
);
|
|
2354
|
+
exit(ExitCode.InvalidArgs);
|
|
2355
|
+
}
|
|
2356
|
+
try {
|
|
2357
|
+
const detail = await discoveryService.getOperation(
|
|
2358
|
+
maybeOpId ? { service: serviceOrId, opId: maybeOpId } : { opId: serviceOrId }
|
|
2359
|
+
);
|
|
2360
|
+
if (format === "json") {
|
|
2361
|
+
printJson(detail);
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
if (format === "markdown") {
|
|
2365
|
+
process.stdout.write(detail.documentation + "\n");
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
printText(detail);
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
const target = maybeOpId ? `${serviceOrId} ${maybeOpId}` : serviceOrId;
|
|
2371
|
+
exitOnApiError(error, {
|
|
2372
|
+
notFoundMessage: `No operation found for "${target}". List operations for a service with \`geni integration operations <service>\`, then re-run with one of those ids.`
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
).addHelpText(
|
|
2377
|
+
"after",
|
|
2378
|
+
`
|
|
2379
|
+
Examples:
|
|
2380
|
+
|
|
2381
|
+
# Look up by bare operation id (server resolves the service):
|
|
2382
|
+
geni integration operation 4c21e1ee-4d54-4413-a4f2-80a80dff4c99
|
|
2383
|
+
|
|
2384
|
+
# Look up with service prefix (works the same, useful when the agent
|
|
2385
|
+
# already knows the service):
|
|
2386
|
+
geni integration operation slack 4c21e1ee-4d54-4413-a4f2-80a80dff4c99
|
|
2387
|
+
|
|
2388
|
+
# Paste-ready for an LLM context window:
|
|
2389
|
+
geni integration operation slack 4c21e1ee... --format markdown
|
|
2390
|
+
|
|
2391
|
+
Find ids first with: geni integration operations <service>
|
|
2392
|
+
`
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
function printText(detail) {
|
|
2396
|
+
const out = process.stdout;
|
|
2397
|
+
out.write(`${detail.service} \xB7 ${detail.title}
|
|
2398
|
+
|
|
2399
|
+
`);
|
|
2400
|
+
if (detail.description) out.write(`${detail.description}
|
|
2401
|
+
|
|
2402
|
+
`);
|
|
2403
|
+
const cleaned = detail.documentation.replace(/^#+\s*/gm, "").replace(/^```[\w-]*$/gm, "");
|
|
2404
|
+
out.write(cleaned);
|
|
2405
|
+
if (!cleaned.endsWith("\n")) out.write("\n");
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// src/commands/integration/index.ts
|
|
2409
|
+
function registerIntegrationCommands(program2) {
|
|
2410
|
+
const integration = program2.command("integration").alias("integrations").description(
|
|
2411
|
+
"Discover the integration catalog: which third-party and platform services the operator's organization can use, the env var names each service's credentials will inject, and per-operation reference docs (HTTP method, params, examples) needed to construct an exec call."
|
|
2412
|
+
).action(() => executeIntegrationList({}));
|
|
2413
|
+
registerIntegrationList(integration);
|
|
2414
|
+
registerIntegrationGet(integration);
|
|
2415
|
+
registerIntegrationOperations(integration);
|
|
2416
|
+
registerIntegrationOperation(integration);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// src/commands/config/get.ts
|
|
2420
|
+
var UNSET_PLACEHOLDER = "(unset)";
|
|
2421
|
+
function executeConfigGet(args) {
|
|
2422
|
+
const file = configService.fileValues();
|
|
2423
|
+
if (args.key) {
|
|
2424
|
+
if (!isSettableConfigKey(args.key)) {
|
|
2425
|
+
printError(
|
|
2426
|
+
`Unknown config key "${args.key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
|
|
2427
|
+
);
|
|
2428
|
+
exit(ExitCode.InvalidArgs);
|
|
2429
|
+
}
|
|
2430
|
+
const value = file[args.key];
|
|
2431
|
+
if (args.json) {
|
|
2432
|
+
printJson({ [args.key]: value ?? null });
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
process.stdout.write((value ?? UNSET_PLACEHOLDER) + "\n");
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
if (args.json) {
|
|
2439
|
+
const payload = {};
|
|
2440
|
+
for (const k of SETTABLE_CONFIG_KEYS) payload[k] = file[k] ?? null;
|
|
2441
|
+
printJson(payload);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
printTable(
|
|
2445
|
+
["KEY", "VALUE"],
|
|
2446
|
+
SETTABLE_CONFIG_KEYS.map((k) => [k, file[k] ?? UNSET_PLACEHOLDER])
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
function registerConfigGet(parent) {
|
|
2450
|
+
parent.command("get").argument(
|
|
2451
|
+
"[key]",
|
|
2452
|
+
`Specific key to read (${SETTABLE_CONFIG_KEYS.join(" | ")}). Omit to list every settable key with its value.`
|
|
2453
|
+
).description(
|
|
2454
|
+
"Print what's written to the persistent config file. Symmetric with `geni config set` \u2014 whatever you wrote is what you read. Unset keys render as `(unset)` in table output and `null` in --json. For the URL the CLI is actually hitting at runtime (which can differ if a runner-session is bound to a different server), run `geni auth status`."
|
|
2455
|
+
).option(
|
|
2456
|
+
"--json",
|
|
2457
|
+
"Machine-readable output. Unset keys are emitted as JSON `null`."
|
|
2458
|
+
).action(
|
|
2459
|
+
(key, opts) => executeConfigGet({ key, json: opts.json })
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/commands/config/set.ts
|
|
2464
|
+
function registerConfigSet(parent) {
|
|
2465
|
+
parent.command("set").argument("<key>", `Config key (${SETTABLE_CONFIG_KEYS.join(" | ")}).`).argument(
|
|
2466
|
+
"<value>",
|
|
2467
|
+
"New value. URL keys must be valid http:// or https:// URLs (validated on write)."
|
|
2468
|
+
).description(
|
|
2469
|
+
"Write a config value. Validated against the schema on write, so a malformed URL fails loudly here rather than waiting for the next CLI command to crash. Refuses to change `apiUrl` while a runner-session is bound to a different server \u2014 logout (or use `geni login --server <url>`) to switch first."
|
|
2470
|
+
).action(async (key, value) => {
|
|
2471
|
+
if (!isSettableConfigKey(key)) {
|
|
2472
|
+
printError(
|
|
2473
|
+
`Unknown config key "${key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
|
|
2474
|
+
);
|
|
2475
|
+
exit(ExitCode.InvalidArgs);
|
|
2476
|
+
}
|
|
2477
|
+
const result = await configService.set({ key, value });
|
|
2478
|
+
if (result.ok) {
|
|
2479
|
+
printSuccess(`Set ${key} = ${value}`);
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
switch (result.reason) {
|
|
2483
|
+
case "invalid":
|
|
2484
|
+
printError(`Invalid value for ${key}: ${result.error}.`);
|
|
2485
|
+
exit(ExitCode.InvalidArgs);
|
|
2486
|
+
// eslint-disable-next-line no-fallthrough
|
|
2487
|
+
case "session_conflict":
|
|
2488
|
+
printError(
|
|
2489
|
+
`Refusing to change apiUrl: an active session is bound to ${result.sessionUrl}.`
|
|
2490
|
+
);
|
|
2491
|
+
printError(
|
|
2492
|
+
`Run \`geni logout\` first, or switch in one step with \`geni login --server ${value}\`.`
|
|
2493
|
+
);
|
|
2494
|
+
exit(ExitCode.GenericError);
|
|
2495
|
+
// eslint-disable-next-line no-fallthrough
|
|
2496
|
+
default: {
|
|
2497
|
+
const _exhaustive = result;
|
|
2498
|
+
throw new Error(
|
|
2499
|
+
`Unhandled set result: ${JSON.stringify(_exhaustive)}`
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/commands/config/unset.ts
|
|
2507
|
+
function registerConfigUnset(parent) {
|
|
2508
|
+
parent.command("unset").argument("<key>", `Config key (${SETTABLE_CONFIG_KEYS.join(" | ")}).`).description(
|
|
2509
|
+
"Remove a key from the config file. The resolver falls back through env vars then the compiled-in default, so unsetting doesn't break the CLI; it just goes to the next layer. When the last key is removed, the file itself is deleted instead of leaving an empty config behind."
|
|
2510
|
+
).action(async (key) => {
|
|
2511
|
+
if (!isSettableConfigKey(key)) {
|
|
2512
|
+
printError(
|
|
2513
|
+
`Unknown config key "${key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
|
|
2514
|
+
);
|
|
2515
|
+
exit(ExitCode.InvalidArgs);
|
|
2516
|
+
}
|
|
2517
|
+
const result = await configService.unset({ key });
|
|
2518
|
+
if (!result.wasSet) {
|
|
2519
|
+
printSuccess(`${key} was already unset.`);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
printSuccess(`Unset ${key}.`);
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// src/commands/config/path.ts
|
|
2527
|
+
function registerConfigPath(parent) {
|
|
2528
|
+
parent.command("path").description(
|
|
2529
|
+
"Print the absolute path to the config file. Useful in shell substitutions: `cat $(geni config path)`, `vim $(geni config path)`. Honors $GENI_CONFIG_DIR; defaults to ~/.config/geni/config.json. Always prints what the path WOULD be, the file may not exist yet."
|
|
2530
|
+
).action(() => {
|
|
2531
|
+
process.stdout.write(configService.path + "\n");
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// src/commands/config/index.ts
|
|
2536
|
+
function registerConfigCommands(program2) {
|
|
2537
|
+
const config = program2.command("config").description(
|
|
2538
|
+
"Read and write the persistent CLI config (`~/.config/geni/config.json` by default; honors $GENI_CONFIG_DIR). Holds defaults the resolver consults when nothing more specific is set, useful for pointing the CLI at a self-hosted or local-dev server without re-passing `--server` or exporting an env var on every shell."
|
|
2539
|
+
).action(() => executeConfigGet({}));
|
|
2540
|
+
registerConfigGet(config);
|
|
2541
|
+
registerConfigSet(config);
|
|
2542
|
+
registerConfigUnset(config);
|
|
2543
|
+
registerConfigPath(config);
|
|
2544
|
+
config.addHelpText(
|
|
2545
|
+
"after",
|
|
2546
|
+
`
|
|
2547
|
+
Settable keys:
|
|
2548
|
+
apiUrl cloud API base URL used at fresh \`geni login\` time
|
|
2549
|
+
dashboardUrl dashboard base URL used by browser-opening commands
|
|
2550
|
+
(e.g. \`geni credential connect\`)
|
|
2551
|
+
|
|
2552
|
+
Resolver precedence for apiUrl (highest wins):
|
|
2553
|
+
1. session file's stored server (locked at \`geni login\` time)
|
|
2554
|
+
2. $GENI_API_URL env var
|
|
2555
|
+
3. \`apiUrl\` in this config
|
|
2556
|
+
4. compiled-in default (https://cloud.generalinput.com)
|
|
2557
|
+
|
|
2558
|
+
Switching API URL after login:
|
|
2559
|
+
$ geni logout
|
|
2560
|
+
$ geni config set apiUrl http://localhost:4111
|
|
2561
|
+
$ geni login
|
|
2562
|
+
The session token is bound to the URL it was minted against, so
|
|
2563
|
+
\`config set apiUrl <new>\` is refused while a session is active \u2014
|
|
2564
|
+
logout (or use \`geni login --server <url>\`) to switch in one step.
|
|
2565
|
+
`
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// src/lib/skills.ts
|
|
2570
|
+
import { homedir as homedir2 } from "os";
|
|
2571
|
+
import { join as join2 } from "path";
|
|
2572
|
+
import {
|
|
2573
|
+
mkdirSync,
|
|
2574
|
+
writeFileSync,
|
|
2575
|
+
existsSync,
|
|
2576
|
+
readFileSync as readFileSync2,
|
|
2577
|
+
unlinkSync,
|
|
2578
|
+
rmSync
|
|
2579
|
+
} from "fs";
|
|
2580
|
+
|
|
2581
|
+
// src/skills/geni.md
|
|
2582
|
+
var geni_default = '---\nname: geni\ndescription: Use the operator\'s connected services (Slack, Gmail, GitHub, Stripe, anything they\'ve authorized in General Input) to fulfill their request. Load this whenever the user wants you to take an action on their behalf against an external SaaS account, fetch data from one of their tools, or wire something up that crosses service boundaries. Powers two modes today, one-off requests via `geni exec bash`, plus workflows (coming soon).\n---\n\n# geni\n\n`geni` is the CLI that gives you (the agent) credentialed access to\nthe operator\'s connected accounts. The cloud injects their tokens as\nenv vars into a fresh bash subprocess; you write the `curl` and\nnever see the secret.\n\n## Two modes\n\n**1. One-off requests (available now).** The operator asks you to do\na thing once: "post a Slack message to #eng," "show me my last 10\nStripe charges," "create a GitHub issue from this thread." You\ndiscover the right credential and operation, then run a single\n`geni exec bash --cred <id>` to do it. This is the entire surface\narea you should use today.\n\n**2. Workflows (coming soon).** Durable, scheduled, reusable runs\n(cron-like jobs, webhook handlers, multi-step pipelines) will live\nunder `geni workflow`. Not supported yet. If the operator asks for\n"every morning at 9," "whenever a webhook fires," or anything that\nneeds to keep running after this session ends, tell them workflows\naren\'t available yet and offer to do the work as a one-off right\nnow instead.\n\n## One-off request flow\n\nAlways run discovery before constructing a request. The operation\ndocs tell you the exact env var names and HTTP shape, derive nothing.\n\n1. **Find a connected credential.** `geni credential list` returns\n one row per credential the operator has access to (id, service,\n title). Filter by service: `geni credential list --service slack`.\n\n2. **If the service isn\'t connected**, search the catalog and prompt\n the operator: `geni integration list -q "<keyword>"`. Then\n `geni credential connect <service>` opens the dashboard for them.\n\n3. **Find the operation you need.** `geni integration operations\n<service>` lists every operation the integration exposes. Filter\n with `-q "<keyword>"`.\n\n4. **Read the operation\'s reference docs.** `geni integration\noperation <id> --format markdown` returns HTTP method, path,\n params, response shape, AND the exact env var names that bash\n will set when this credential is declared. **Always read this\n before constructing a curl.** Guessing at URLs or env var names\n is the most common failure mode.\n\n5. **Run the call.**\n ```\n geni exec bash --cred <cred_id> --reason "<what + why>" \\\n -- \'<bash command using the env vars>\'\n ```\n\n## Env var contract\n\nEvery credential\'s env vars are on the response of step 5\n(`credentials_resolved`) and on the operation docs from step 4. Read\nthem, don\'t derive.\n\n- **Per-field, suffixed**: `<SERVICE>_<FIELD>_<id>` (e.g.\n `$SLACK_ACCESS_TOKEN_ABC`, `$SALESFORCE_INSTANCE_URL_KG`). The\n `<id>` suffix is the credential id with `cred_` stripped, uppercased.\n Suffix means two credentials of the same service can coexist in one\n call without colliding.\n- **Canonical aliases (unsuffixed)**: well-known SDKs read fixed env\n var names (`$GH_TOKEN`, `$GITHUB_TOKEN`, `$SLACK_BOT_TOKEN`,\n `$OPENAI_API_KEY`, `$ANTHROPIC_API_KEY`, `$STRIPE_API_KEY`).\n These are emitted alongside the suffixed names for tools that\n hardcode them.\n- **Always-injected platform vars**: `$PLATFORM_API_KEY` /\n `$PLATFORM_BASE_URL` are set on every `geni exec bash` (no\n `--cred` needed). Use them to call General Input\'s first-party\n services (image gen, weather, send-email, etc.).\n\n## Reasons matter\n\nThe `--reason` you supply on every `geni exec bash --cred ...`\ncall lands in the credential access log and is shown to the\noperator. Be specific: "Posting daily digest to #engineering on the\noperator\'s request" is good; "Slack call" is not. Re-state the reason\non every call. The audit log is per-call, not per-session.\n\n## Output is scrubbed\n\nstdout/stderr from the bash subprocess pass through a streaming\nscrubber that replaces every literal occurrence of a registered\nsecret with `[REDACTED:credential_<id>]`. You won\'t see the token\nback. Tools like `echo $TOKEN | base64` don\'t help either, the\nscrubber knows the common encodings.\n\n## Patterns\n\n**One credential, simple curl:**\n\n```\ngeni exec bash --cred cred_01HX --reason "Listing Slack channels" \\\n -- \'curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/conversations.list | jq .channels\'\n```\n\n**Multiple credentials, fan-out (suffix keeps them distinct):**\n\n```\ngeni exec bash \\\n --cred cred_slackA --reason "Posting to #engineering" \\\n --cred cred_slackB --reason "Posting to #marketing" \\\n -- \'curl ... $SLACK_ACCESS_TOKEN_SLACKA ...; curl ... $SLACK_ACCESS_TOKEN_SLACKB ...\'\n```\n\n**Platform service (no --cred needed):**\n\n```\ngeni exec bash \\\n -- \'curl -s -X POST "$PLATFORM_BASE_URL/v1/web-search" \\\n -H "Authorization: Bearer $PLATFORM_API_KEY" \\\n -d "{\\"query\\":\\"general input\\"}"\'\n```\n\n## Exit codes\n\n| Code | Meaning |\n| ----- | -------------------------------------------------------------------- |\n| 0 | Subprocess succeeded |\n| 1\u2013125 | Subprocess\'s own exit code (curl failed, jq parse error, etc.) |\n| 4 | Resource not found (cred id, integration slug, operation id) |\n| 5 | Forbidden, session valid but no access to this resource |\n| 9 | Validation failed (bad flag combo, malformed input) |\n| 77 | Server refused to resolve a credential. Don\'t retry, surface to user |\n| 78 | Runner session missing or expired. Operator runs `geni login` |\n| 124 | Timeout |\n| 125 | Internal CLI error |\n\n## Use `--json` everywhere for programmatic output\n\nEvery list/get command supports `--json` for machine-readable shape.\nUse it whenever you\'re going to parse the response. The table output\nis meant for humans.\n\n## Don\'t\n\n- Don\'t construct a curl without reading `geni integration operation\n<id>` first. The HTTP shape, required headers, and env var names\n are all in the operation docs; deriving them from the service slug\n is how you end up with 401s.\n- Don\'t reach for `run_managed_script` or any JS-script tool. `geni\nexec bash` is the only execution primitive locally. If you need\n npm packages, your bash command can call `bun --install auto run -e\n\'...\'` (if bun is installed locally) or `python3 -c \'...\'` for\n Python.\n- Don\'t promise the operator a recurring or scheduled job. Workflows\n are coming soon but not shipped. Offer the one-off equivalent now.\n';
|
|
2583
|
+
|
|
2584
|
+
// src/lib/skills.ts
|
|
2585
|
+
var GENI_SKILL_NAME = "geni";
|
|
2586
|
+
var GENI_SKILL_MD = geni_default;
|
|
2587
|
+
var home = homedir2();
|
|
2588
|
+
var claudeSkillsDir = join2(home, ".claude", "skills");
|
|
2589
|
+
var claudeGeniDir = join2(claudeSkillsDir, GENI_SKILL_NAME);
|
|
2590
|
+
var agentSkillsDir = join2(home, ".agents", "skills");
|
|
2591
|
+
var agentSkillsGeniDir = join2(agentSkillsDir, GENI_SKILL_NAME);
|
|
2592
|
+
var TARGETS = [
|
|
2593
|
+
{
|
|
2594
|
+
name: "Claude Code",
|
|
2595
|
+
detect: () => existsSync(join2(home, ".claude")),
|
|
2596
|
+
skillDir: claudeGeniDir,
|
|
2597
|
+
skillPath: join2(claudeGeniDir, "SKILL.md"),
|
|
2598
|
+
// Earlier versions of `geni skills install` wrote a loose .md file
|
|
2599
|
+
// at `~/.claude/skills/geni.md`, which Claude Code silently ignores.
|
|
2600
|
+
// Clean it up on install/uninstall so upgraders aren't left with a
|
|
2601
|
+
// stale sibling that confuses `doctor`.
|
|
2602
|
+
legacyPaths: [join2(claudeSkillsDir, `${GENI_SKILL_NAME}.md`)]
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
name: "Codex CLI / Gemini CLI / VS Code Copilot",
|
|
2606
|
+
// Detection: any of the supporting agents has touched disk, or the
|
|
2607
|
+
// shared `~/.agents/` dir already exists (likely written by another
|
|
2608
|
+
// installer). We don't probe for VS Code Copilot Chat directly
|
|
2609
|
+
// because it lives inside VS Code's user data with no clean
|
|
2610
|
+
// home-dir signal — Copilot users typically have one of the CLIs
|
|
2611
|
+
// installed too, and the install is idempotent if they don't.
|
|
2612
|
+
detect: () => existsSync(join2(home, ".codex")) || existsSync(join2(home, ".gemini")) || existsSync(join2(home, ".agents")),
|
|
2613
|
+
skillDir: agentSkillsGeniDir,
|
|
2614
|
+
skillPath: join2(agentSkillsGeniDir, "SKILL.md"),
|
|
2615
|
+
legacyPaths: []
|
|
2616
|
+
}
|
|
2617
|
+
];
|
|
2618
|
+
function detectSkillTargets() {
|
|
2619
|
+
const out = [];
|
|
2620
|
+
for (const target of TARGETS) {
|
|
2621
|
+
if (!target.detect()) continue;
|
|
2622
|
+
out.push({ name: target.name, path: target.skillPath });
|
|
2623
|
+
}
|
|
2624
|
+
return out;
|
|
2625
|
+
}
|
|
2626
|
+
function installSkills() {
|
|
2627
|
+
const results = [];
|
|
2628
|
+
for (const target of TARGETS) {
|
|
2629
|
+
if (!target.detect()) continue;
|
|
2630
|
+
const path = target.skillPath;
|
|
2631
|
+
try {
|
|
2632
|
+
mkdirSync(target.skillDir, { recursive: true });
|
|
2633
|
+
const previous = existsSync(path) ? readFileSync2(path, "utf-8") : null;
|
|
2634
|
+
const changed = previous !== GENI_SKILL_MD;
|
|
2635
|
+
writeFileSync(path, GENI_SKILL_MD);
|
|
2636
|
+
for (const legacy of target.legacyPaths) {
|
|
2637
|
+
if (existsSync(legacy)) unlinkSync(legacy);
|
|
2638
|
+
}
|
|
2639
|
+
results.push({
|
|
2640
|
+
name: target.name,
|
|
2641
|
+
path,
|
|
2642
|
+
status: changed ? "updated" : "unchanged"
|
|
2643
|
+
});
|
|
2644
|
+
} catch (err) {
|
|
2645
|
+
results.push({
|
|
2646
|
+
name: target.name,
|
|
2647
|
+
path,
|
|
2648
|
+
status: "failed",
|
|
2649
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
return results;
|
|
2654
|
+
}
|
|
2655
|
+
function uninstallSkills() {
|
|
2656
|
+
const results = [];
|
|
2657
|
+
for (const target of TARGETS) {
|
|
2658
|
+
if (!target.detect()) continue;
|
|
2659
|
+
const dir = target.skillDir;
|
|
2660
|
+
const path = target.skillPath;
|
|
2661
|
+
const legacies = target.legacyPaths.filter((p2) => existsSync(p2));
|
|
2662
|
+
if (!existsSync(dir) && legacies.length === 0) {
|
|
2663
|
+
results.push({ name: target.name, path, status: "absent" });
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
try {
|
|
2667
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2668
|
+
for (const legacy of legacies) unlinkSync(legacy);
|
|
2669
|
+
results.push({ name: target.name, path, status: "removed" });
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
results.push({
|
|
2672
|
+
name: target.name,
|
|
2673
|
+
path,
|
|
2674
|
+
status: "failed",
|
|
2675
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return results;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// src/commands/skills/install.ts
|
|
2683
|
+
function registerSkillsInstall(parent) {
|
|
2684
|
+
parent.command("install").description(
|
|
2685
|
+
"Install the bundled geni skill into every detected AI agent (Claude Code, Codex CLI, Gemini CLI, VS Code Copilot Chat). Idempotent, safe to re-run after upgrading geni."
|
|
2686
|
+
).action(() => {
|
|
2687
|
+
const targets = detectSkillTargets();
|
|
2688
|
+
if (targets.length === 0) {
|
|
2689
|
+
printWarning(
|
|
2690
|
+
"No supported AI agent detected. Install Claude Code (https://claude.ai/download), Codex CLI, or Gemini CLI first, then re-run `geni skills install`."
|
|
2691
|
+
);
|
|
2692
|
+
exit(ExitCode.NotFound);
|
|
2693
|
+
}
|
|
2694
|
+
const results = installSkills();
|
|
2695
|
+
for (const r of results) {
|
|
2696
|
+
if (r.status === "failed") {
|
|
2697
|
+
printError(`${r.name}: ${r.error ?? "failed"} (${r.path})`);
|
|
2698
|
+
continue;
|
|
2699
|
+
}
|
|
2700
|
+
if (r.status === "updated") {
|
|
2701
|
+
printSuccess(`${r.name}: ${r.path}`);
|
|
2702
|
+
} else {
|
|
2703
|
+
printInfo(`${r.name}: ${r.path} (already up to date)`);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
const failed = results.some((r) => r.status === "failed");
|
|
2707
|
+
exit(failed ? ExitCode.InternalError : ExitCode.Ok);
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/commands/skills/uninstall.ts
|
|
2712
|
+
function registerSkillsUninstall(parent) {
|
|
2713
|
+
parent.command("uninstall").description(
|
|
2714
|
+
"Remove the geni skill from every detected AI agent. Leaves the agent itself untouched."
|
|
2715
|
+
).action(() => {
|
|
2716
|
+
const results = uninstallSkills();
|
|
2717
|
+
if (results.length === 0) {
|
|
2718
|
+
printInfo("No supported AI agent detected; nothing to remove.");
|
|
2719
|
+
exit(ExitCode.Ok);
|
|
2720
|
+
}
|
|
2721
|
+
for (const r of results) {
|
|
2722
|
+
if (r.status === "failed") {
|
|
2723
|
+
printError(`${r.name}: ${r.error ?? "failed"} (${r.path})`);
|
|
2724
|
+
continue;
|
|
2725
|
+
}
|
|
2726
|
+
if (r.status === "removed") {
|
|
2727
|
+
printSuccess(`${r.name}: removed ${r.path}`);
|
|
2728
|
+
} else {
|
|
2729
|
+
printInfo(`${r.name}: nothing to remove (${r.path} not present)`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
const failed = results.some((r) => r.status === "failed");
|
|
2733
|
+
exit(failed ? ExitCode.InternalError : ExitCode.Ok);
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// src/commands/skills/index.ts
|
|
2738
|
+
function registerSkillsCommands(program2) {
|
|
2739
|
+
const skills = program2.command("skills").description(
|
|
2740
|
+
"Install (or remove) the agent-facing geni instructions in your AI coding agent. Detects Claude Code (others coming) and writes a skill file the agent loads automatically."
|
|
2741
|
+
);
|
|
2742
|
+
registerSkillsInstall(skills);
|
|
2743
|
+
registerSkillsUninstall(skills);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/commands/doctor.ts
|
|
2747
|
+
import chalk8 from "chalk";
|
|
2748
|
+
|
|
2749
|
+
// src/lib/preflight.ts
|
|
2750
|
+
import { delimiter, join as join3 } from "path";
|
|
2751
|
+
import { accessSync, constants } from "fs";
|
|
2752
|
+
var REQUIRED_RUNTIME_DEPS = ["bash", "curl", "jq"];
|
|
2753
|
+
function findOnPath(name) {
|
|
2754
|
+
const path = process.env.PATH;
|
|
2755
|
+
if (!path) return null;
|
|
2756
|
+
for (const dir of path.split(delimiter)) {
|
|
2757
|
+
if (dir.length === 0) continue;
|
|
2758
|
+
const candidate = join3(dir, name);
|
|
2759
|
+
try {
|
|
2760
|
+
accessSync(candidate, constants.X_OK);
|
|
2761
|
+
return candidate;
|
|
2762
|
+
} catch {
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
return null;
|
|
2766
|
+
}
|
|
2767
|
+
function checkRuntimeDeps() {
|
|
2768
|
+
const found = {};
|
|
2769
|
+
const missing = [];
|
|
2770
|
+
for (const dep of REQUIRED_RUNTIME_DEPS) {
|
|
2771
|
+
const path = findOnPath(dep);
|
|
2772
|
+
found[dep] = path;
|
|
2773
|
+
if (path === null) missing.push(dep);
|
|
2774
|
+
}
|
|
2775
|
+
return { missing, found };
|
|
2776
|
+
}
|
|
2777
|
+
function requireRuntimeDeps() {
|
|
2778
|
+
const { missing } = checkRuntimeDeps();
|
|
2779
|
+
if (missing.length === 0) return;
|
|
2780
|
+
printError(
|
|
2781
|
+
`Missing required tool(s) on $PATH: ${missing.join(", ")}. ${installHint(missing)}`
|
|
2782
|
+
);
|
|
2783
|
+
exit(ExitCode.InternalError);
|
|
2784
|
+
}
|
|
2785
|
+
function installHint(deps) {
|
|
2786
|
+
const list = deps.join(" ");
|
|
2787
|
+
switch (process.platform) {
|
|
2788
|
+
case "darwin":
|
|
2789
|
+
return `Install with: \`brew install ${list}\``;
|
|
2790
|
+
case "linux":
|
|
2791
|
+
return `Install with your distro's package manager, e.g. \`sudo apt install ${list}\` or \`sudo dnf install ${list}\``;
|
|
2792
|
+
default:
|
|
2793
|
+
return `Install ${list} before re-running.`;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// src/commands/doctor.ts
|
|
2798
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
2799
|
+
function registerDoctorCommand(program2) {
|
|
2800
|
+
program2.command("doctor").description(
|
|
2801
|
+
"Diagnose your geni setup: required system tools, active session, network reach to the cloud, and skill installation across detected AI agents. Prints a checklist with \u2713/\u2717 for each."
|
|
2802
|
+
).action(async () => {
|
|
2803
|
+
const checks = [];
|
|
2804
|
+
checks.push(...runtimeDepsCheck());
|
|
2805
|
+
checks.push(...await sessionCheck());
|
|
2806
|
+
checks.push(...skillsCheck());
|
|
2807
|
+
printReport(checks);
|
|
2808
|
+
const failures = checks.filter((c) => c.status === "fail").length;
|
|
2809
|
+
exit(failures > 0 ? ExitCode.GenericError : ExitCode.Ok);
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
function runtimeDepsCheck() {
|
|
2813
|
+
const { found, missing } = checkRuntimeDeps();
|
|
2814
|
+
const out = [];
|
|
2815
|
+
for (const dep of REQUIRED_RUNTIME_DEPS) {
|
|
2816
|
+
const path = found[dep];
|
|
2817
|
+
if (path) {
|
|
2818
|
+
out.push({
|
|
2819
|
+
label: `${dep} on $PATH`,
|
|
2820
|
+
status: "pass",
|
|
2821
|
+
detail: path
|
|
2822
|
+
});
|
|
2823
|
+
} else {
|
|
2824
|
+
out.push({
|
|
2825
|
+
label: `${dep} on $PATH`,
|
|
2826
|
+
status: "fail",
|
|
2827
|
+
detail: `not found. ${installHint([dep])}`
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
if (missing.length > 1) {
|
|
2832
|
+
out.push({
|
|
2833
|
+
label: "install hint",
|
|
2834
|
+
status: "warn",
|
|
2835
|
+
detail: installHint(missing)
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
return out;
|
|
2839
|
+
}
|
|
2840
|
+
async function sessionCheck() {
|
|
2841
|
+
const session = await sessionContextService.load();
|
|
2842
|
+
if (!session) {
|
|
2843
|
+
return [
|
|
2844
|
+
{
|
|
2845
|
+
label: "runner session",
|
|
2846
|
+
status: "fail",
|
|
2847
|
+
detail: "no session on disk. Run `geni login` to authenticate."
|
|
2848
|
+
}
|
|
2849
|
+
];
|
|
2850
|
+
}
|
|
2851
|
+
const out = [
|
|
2852
|
+
{
|
|
2853
|
+
label: "runner session",
|
|
2854
|
+
status: "pass",
|
|
2855
|
+
detail: `${session.user.email ?? session.user.id} on ${session.server}`
|
|
2856
|
+
},
|
|
2857
|
+
{
|
|
2858
|
+
label: "active workspace",
|
|
2859
|
+
status: "pass",
|
|
2860
|
+
detail: `${session.workspace.slug} (${session.workspace.role})`
|
|
2861
|
+
}
|
|
2862
|
+
];
|
|
2863
|
+
try {
|
|
2864
|
+
await authService.status();
|
|
2865
|
+
out.push({
|
|
2866
|
+
label: "server reachable + session valid",
|
|
2867
|
+
status: "pass",
|
|
2868
|
+
detail: session.server
|
|
2869
|
+
});
|
|
2870
|
+
} catch (err) {
|
|
2871
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2872
|
+
out.push({
|
|
2873
|
+
label: "server reachable + session valid",
|
|
2874
|
+
status: "fail",
|
|
2875
|
+
detail: "session token rejected. Run `geni login` to re-authenticate."
|
|
2876
|
+
});
|
|
2877
|
+
} else {
|
|
2878
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2879
|
+
out.push({
|
|
2880
|
+
label: "server reachable",
|
|
2881
|
+
status: "fail",
|
|
2882
|
+
detail: `${session.server} unreachable: ${detail}`
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
return out;
|
|
2887
|
+
}
|
|
2888
|
+
function skillsCheck() {
|
|
2889
|
+
const targets = detectSkillTargets();
|
|
2890
|
+
if (targets.length === 0) {
|
|
2891
|
+
return [
|
|
2892
|
+
{
|
|
2893
|
+
label: "AI agent detected",
|
|
2894
|
+
status: "warn",
|
|
2895
|
+
detail: "no supported agent installed yet. Install Claude Code (https://claude.ai/download) and re-run `geni doctor`."
|
|
2896
|
+
}
|
|
2897
|
+
];
|
|
2898
|
+
}
|
|
2899
|
+
const out = [];
|
|
2900
|
+
for (const target of targets) {
|
|
2901
|
+
if (!existsSync2(target.path)) {
|
|
2902
|
+
out.push({
|
|
2903
|
+
label: `${target.name} skill installed`,
|
|
2904
|
+
status: "fail",
|
|
2905
|
+
detail: `${target.path} missing. Run \`geni skills install\` to write it.`
|
|
2906
|
+
});
|
|
2907
|
+
continue;
|
|
2908
|
+
}
|
|
2909
|
+
const onDisk = readFileSync3(target.path, "utf-8");
|
|
2910
|
+
if (onDisk === GENI_SKILL_MD) {
|
|
2911
|
+
out.push({
|
|
2912
|
+
label: `${target.name} skill installed`,
|
|
2913
|
+
status: "pass",
|
|
2914
|
+
detail: `${target.path} (current)`
|
|
2915
|
+
});
|
|
2916
|
+
} else {
|
|
2917
|
+
out.push({
|
|
2918
|
+
label: `${target.name} skill installed`,
|
|
2919
|
+
status: "warn",
|
|
2920
|
+
detail: `${target.path} is out of date. Run \`geni skills install\` to refresh.`
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
return out;
|
|
2925
|
+
}
|
|
2926
|
+
function printReport(checks) {
|
|
2927
|
+
for (const check of checks) {
|
|
2928
|
+
const mark = check.status === "pass" ? chalk8.green("\u2713") : check.status === "warn" ? chalk8.yellow("!") : chalk8.red("\u2717");
|
|
2929
|
+
process.stdout.write(`${mark} ${check.label}
|
|
2930
|
+
`);
|
|
2931
|
+
process.stdout.write(` ${chalk8.dim(check.detail)}
|
|
2932
|
+
`);
|
|
2933
|
+
}
|
|
2934
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
2935
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
2936
|
+
process.stdout.write("\n");
|
|
2937
|
+
if (fails === 0 && warns === 0) {
|
|
2938
|
+
process.stdout.write(chalk8.green("All checks passed.\n"));
|
|
2939
|
+
} else {
|
|
2940
|
+
process.stdout.write(
|
|
2941
|
+
`${fails} failure${fails === 1 ? "" : "s"}, ${warns} warning${warns === 1 ? "" : "s"}.
|
|
2942
|
+
`
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// src/cli.ts
|
|
2948
|
+
var program = new Command();
|
|
2949
|
+
program.name("geni").description(
|
|
2950
|
+
"The agent-facing CLI for General Input. Discover the integrations and credentials the operator has connected, then run shell commands with those credentials injected as env vars by the cloud (audit-logged, output-scrubbed)."
|
|
2951
|
+
).version(CLI_VERSION, "-v, --version", "Print the geni version and exit.").showHelpAfterError().option(
|
|
2952
|
+
"--workspace <slug>",
|
|
2953
|
+
"Override the active workspace for this invocation. Without it, commands run against the workspace set by `geni workspace switch`."
|
|
2954
|
+
);
|
|
2955
|
+
registerAuthCommands(program);
|
|
2956
|
+
registerWorkspaceCommands(program);
|
|
2957
|
+
registerExecCommands(program);
|
|
2958
|
+
registerCredentialCommands(program);
|
|
2959
|
+
registerIntegrationCommands(program);
|
|
2960
|
+
registerConfigCommands(program);
|
|
2961
|
+
registerSkillsCommands(program);
|
|
2962
|
+
registerDoctorCommand(program);
|
|
2963
|
+
program.addHelpText(
|
|
2964
|
+
"after",
|
|
2965
|
+
`
|
|
2966
|
+
Typical agent flow:
|
|
2967
|
+
1. geni integration list -q "<keyword>" find a service
|
|
2968
|
+
2. geni integration operations <service> -q "..." find an operation
|
|
2969
|
+
3. geni integration operation <opId> read its reference (env vars, auth header, example). ALWAYS do this before writing a curl
|
|
2970
|
+
4. geni credential list --service <service> find a connected credential
|
|
2971
|
+
5. geni exec bash --cred <id> --reason "..." -- '<command>'
|
|
2972
|
+
|
|
2973
|
+
Discovery (steps 1-4) is read-only and free; only step 5 resolves
|
|
2974
|
+
credentials and runs code, audit-logged with the reason you supplied.
|
|
2975
|
+
|
|
2976
|
+
First time? geni login then geni config get to verify the API URL.
|
|
2977
|
+
Run any command with --help for its full reference.`
|
|
2978
|
+
);
|
|
2979
|
+
if (shouldRunPreflight(process.argv)) {
|
|
2980
|
+
requireRuntimeDeps();
|
|
2981
|
+
}
|
|
2982
|
+
function shouldRunPreflight(argv) {
|
|
2983
|
+
const firstArg = argv[2];
|
|
2984
|
+
if (!firstArg) return false;
|
|
2985
|
+
if (firstArg === "doctor") return false;
|
|
2986
|
+
if (firstArg === "--help" || firstArg === "-h") return false;
|
|
2987
|
+
if (firstArg === "--version" || firstArg === "-v") return false;
|
|
2988
|
+
return true;
|
|
2989
|
+
}
|
|
2990
|
+
try {
|
|
2991
|
+
await program.parseAsync(process.argv);
|
|
2992
|
+
} catch (err) {
|
|
2993
|
+
if (err instanceof ApiError && err.status === 426) {
|
|
2994
|
+
printError(err.message);
|
|
2995
|
+
exit(ExitCode.UpgradeRequired);
|
|
2996
|
+
}
|
|
2997
|
+
throw err;
|
|
2998
|
+
}
|
|
2999
|
+
//# sourceMappingURL=cli.js.map
|