@gondoai/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/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Gondo CLI
2
+
3
+ Give a coding agent access to [Gondo](https://www.gondo.ai) to create employees, build and test their jobs, and inspect results. The CLI calls Gondo's HTTP API and loads its current authoring guide from the service.
4
+
5
+ Requires **Node.js 22.12 or later** and a Gondo API key. API access requires Pro, an active Pro trial, or complimentary Pro. Installing the CLI does not enable API access.
6
+
7
+ ## Get started
8
+
9
+ 1. In Gondo, open **Account → API keys** and create a key. This requires an account admin. The key is all your agent needs.
10
+ 2. Save the key in a local file called `gondo.env`, outside your source repository:
11
+
12
+ ```dotenv
13
+ GONDO_API_KEY=your-api-key
14
+ ```
15
+
16
+ 3. From any directory, read the guide:
17
+
18
+ ```bash
19
+ npx --yes @gondoai/cli --env-file /absolute/path/to/gondo.env guide
20
+ ```
21
+
22
+ No Gondo app checkout is needed. To install a persistent `gondo` command instead:
23
+
24
+ ```bash
25
+ npm install --global @gondoai/cli
26
+ gondo --env-file /absolute/path/to/gondo.env guide
27
+ ```
28
+
29
+ `--env-file` accepts dotenv syntax, including quoted values. Existing environment variables take precedence over the file. If `GONDO_API_KEY` is already set in your agent's environment, omit the flag. The CLI never loads an env file implicitly. It connects to `https://runtime.gondo.ai` and discovers the account belonging to your key automatically. For development, set `GONDO_API_URL` to another runtime; `GONDO_ACCOUNT_ID` remains an optional override for existing setups. Overrides do not change which account a key can access.
30
+
31
+ Keep the key out of prompts, source control, and shared logs. Give your agent the local env-file path, not the key text. Connect required integrations using the setup commands below or in Gondo; enable Public browsing for public websites.
32
+
33
+ ## Give this to your agent
34
+
35
+ Use a coding agent with terminal access, such as Codex or Claude Code. Replace the task and local file path:
36
+
37
+ > Use Gondo to build an employee that **[describe the task]**. Your credentials are in **[/absolute/path/to/gondo.env]**; do not print them. Start by running `npx --yes @gondoai/cli --env-file /absolute/path/to/gondo.env guide`. Read the workflow and node guides as directed, then inspect the available integrations. Create a new employee and job, validate it, test with **[agreed inputs]**, and inspect the actual run output. Publish the new job while leaving it disabled for my review. Do not change existing jobs or send messages unless my task explicitly requires it. Report the employee and job IDs and the test results.
38
+
39
+ Commands shown as `gondo …` in the served guide can all be run as `npx --yes @gondoai/cli --env-file /absolute/path/to/gondo.env …`. If an older guide mentions `pnpm gondo` or the app checkout, use this npm command instead.
40
+
41
+ ## Useful commands
42
+
43
+ After a global install, with credentials in `gondo.env`:
44
+
45
+ ```bash
46
+ gondo --env-file ./gondo.env guide --topic workflows
47
+ gondo --env-file ./gondo.env guide --topic nodes
48
+ gondo --env-file ./gondo.env list /integrations
49
+ gondo --env-file ./gondo.env employees list
50
+ gondo --env-file ./gondo.env workflows list
51
+ gondo --help
52
+ gondo --version
53
+ ```
54
+
55
+ The command name remains `workflows`; Gondo calls them jobs in the app. The guide documents authoring, execution, file upload/download, resumable workspaces, browser login handoffs, and run inspection. Human approvals are completed by a signed-in person in Gondo.
56
+
57
+ API responses are JSON. Exit codes: **0** success, **1** failure, **2** user action required. Tests and investigation code execute real actions through your connected integrations. The CLI does not retry mutations automatically; inspect the run or execution after a timeout before trying again.
58
+
59
+ ## Development and publishing
60
+
61
+ The service must support `GET /api/operator/me` before publishing this key-only CLI release. That endpoint authenticates the key and returns its account; the Pro requirements still apply.
62
+
63
+ This repository owns the standalone CLI. Workflow schemas, authoring guides, and authorization remain in the Gondo service. The initial client was extracted from the app's existing operator CLI; it has one runtime dependency and no build step.
64
+
65
+ ```bash
66
+ npm ci
67
+ npm test
68
+ ```
69
+
70
+ Tests exercise the packed npm artifact installed in a separate temporary directory, including its executable, env-file loading, HTTP authentication, and YAML requests. No Gondo credentials or live account are required.
71
+
72
+ To publish the prepared `@gondoai/cli@0.1.0` release, sign in with an npm account that can publish to the `gondoai` organisation:
73
+
74
+ ```bash
75
+ npm login
76
+ npm whoami
77
+ npm pack --dry-run
78
+ npm publish --access public
79
+ ```
80
+
81
+ Publishing runs the tests again. Complete npm's authentication/2FA prompt when requested. Then verify the registry install from any other directory:
82
+
83
+ ```bash
84
+ npx --yes @gondoai/cli@0.1.0 --version
85
+ npx --yes @gondoai/cli@0.1.0 --env-file /absolute/path/to/gondo.env guide
86
+ ```
87
+
88
+ `0.1.0` is prepared for its first publication; it is not published by creating or pushing this repository. The npm package is `@gondoai/cli`; its installed executable is `gondo`. Future releases need a new version number. The package is public with no open-source license grant (`UNLICENSED`).
89
+
90
+ ## Connection setup and API-key scopes
91
+
92
+ All keys have `operator` access. In Account → API keys, admins can additionally select **Manage integrations** (`integrations:manage`) when creating a key. Existing keys do not gain this permission automatically; create a replacement and revoke the old key to change permissions. Ordinary keys can already use connected APIs, including writes; this additional scope controls connection setup, not provider API permissions. Keys cannot grant scopes or approve human reviews.
93
+
94
+ ```sh
95
+ gondo integrations providers
96
+ gondo integrations provider clio
97
+ gondo integrations create --provider clio --name "Demo Clio"
98
+ gondo integrations credentials <id> --file ./private-credentials.json --variant <variant>
99
+ gondo integrations test <id>
100
+ gondo integrations enable <id>
101
+ ```
102
+
103
+ Only prebuilt providers with supplied credentials are supported. Credential JSON/YAML must match the displayed schema; `--file -` reads stdin. Do not pass secret values as arguments. Connections start disabled, and enablement is explicit. Replacement credentials are validated separately before an atomic switch; failed checks leave live credentials untouched. Concurrent replacements return a conflict rather than overwriting each other. `checked: false` means no provider check was available. Use `integrations list|get <id>`, `disable <id>`, or `update <id> --file settings.json` (fields: `label`, `maxScope`, `enabledToolsets`, `disabledTools`). Provider metadata lists valid tool names; `maxScope` accepts `read`, `write`, `admin`. These settings govern named tools, not direct API code.
104
+
105
+ ## Webhooks and workflow documents
106
+
107
+ ```sh
108
+ gondo workflows webhook get <workflow-id>
109
+ gondo workflows webhook configure <workflow-id> --output ./private-webhook.json
110
+ gondo attempts files list <attempt-id>
111
+ gondo attempts files download <attempt-id> <artifact-id> --output-dir ./outputs
112
+ ```
113
+
114
+ Webhook configuration requires a new private output file and never prints its secret. Repeating `configure` preserves existing credentials; explicitly use `rotate-secret` with another output file to replace a lost secret. Trigger the returned URL with curl using its `headerName` and `secret`, then inspect runs. No mutations are automatically retried.
115
+
116
+ Workflow downloads target an attempt, not a CLI workspace session. Downloads refuse unsafe filenames or overwrites, remove partial files, verify available size/checksum metadata and cap files without size metadata at 512 MiB. Storage requests never carry the Gondo API key.
117
+
118
+ Deploy the backend API-key scope migration and matching runtime before releasing this CLI. No SharePoint file-transfer changes are part of this release.
package/bin/gondo.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.mjs'
3
+
4
+ process.exitCode = await runCli(process.argv.slice(2))
@@ -0,0 +1 @@
1
+ GONDO_API_KEY=your-api-key
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@gondoai/cli",
3
+ "version": "0.1.0",
4
+ "description": "Let coding agents build, test, and manage employees and jobs on Gondo.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=22.12.0"
9
+ },
10
+ "bin": {
11
+ "gondo": "bin/gondo.mjs"
12
+ },
13
+ "exports": "./src/cli.mjs",
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "gondo.env.example"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test",
21
+ "prepublishOnly": "npm test"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/commandable/gondo-cli.git"
26
+ },
27
+ "homepage": "https://github.com/commandable/gondo-cli#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/commandable/gondo-cli/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/"
34
+ },
35
+ "keywords": ["gondo", "cli", "agents", "workflows"],
36
+ "dependencies": {
37
+ "yaml": "^2.8.3"
38
+ }
39
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,731 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'
4
+ import { basename, resolve } from 'node:path'
5
+ import { Readable, Transform } from 'node:stream'
6
+ import { pipeline } from 'node:stream/promises'
7
+ import { parseArgs, parseEnv } from 'node:util'
8
+ import packageJson from '../package.json' with { type: 'json' }
9
+ import { parseDocument } from 'yaml'
10
+
11
+ export const HELP = `Gondo account operator CLI
12
+
13
+ Set GONDO_API_KEY. The production URL and your key's account are automatic.
14
+ Optional overrides: GONDO_API_URL (development runtime), GONDO_ACCOUNT_ID.
15
+ Load a local env file with: gondo --env-file ./gondo.env guide
16
+ Use gondo --version to show the installed CLI version.
17
+ Account admins create keys in Account Settings. Never put a key in source code.
18
+
19
+ gondo guide [--topic overview|workflows|nodes]
20
+ gondo list [namespace-path] [--limit 25]
21
+ gondo read <namespace-path>
22
+ gondo employees list|get <id>|create --file employee.yaml|update <id> --file changes.json|delete <id>
23
+ gondo workflows list|get <id>|create --employee <id> --name <name>
24
+ gondo workflows save-draft <id> --file workflow.yaml
25
+ gondo workflows validate <id> [--file workflow.yaml] [--source draft|published]
26
+ gondo workflows export <id> [--source active|draft|published] [--output workflow.yaml]
27
+ gondo workflows publish|enable|disable|discard-draft|delete <id>
28
+ gondo workflows rename <id> --name <name>
29
+ gondo runs list|get|attempts|events|definition|cancel <run-id>
30
+ gondo attempts get|events|definition|cancel <attempt-id>
31
+ gondo runs test|start <workflow-id> [--input input.json]
32
+ gondo exec --file investigate.js --integrations ref_one,ref_two [--session <id>] [--attach <path> ...] [--output-dir <dir>]
33
+ gondo sessions create|list|get <id>|close <id>
34
+ gondo files list <session-id>|upload <session-id> --file <path>
35
+ gondo files download <file-id> --session <id> --output-dir <dir>
36
+ gondo executions get <execution-id> --session <id>
37
+ gondo browser restart <session-id> --integration <ref>
38
+ gondo integrations providers|provider <provider>|list|get <id>
39
+ gondo integrations create --provider <provider> --name <name> [--reference <ref>]
40
+ gondo integrations credentials <id> --file credentials.json|- [--variant <variant>]
41
+ gondo integrations test|enable|disable <id>
42
+ gondo integrations update <id> --file settings.json
43
+ gondo workflows webhook get|configure|rotate-secret <workflow-id> [--output <private-file>]
44
+ gondo attempts files list <attempt-id>
45
+ gondo attempts files download <attempt-id> <artifact-id> --output-dir <dir>
46
+
47
+ Keys have operator access; integration changes also require integrations:manage.
48
+ New integrations are disabled. Save credentials, inspect the check result, then enable explicitly.
49
+ Webhook configure/rotate-secret require --output; secrets never appear on stdout. Configure never rotates an existing secret.
50
+
51
+ Requires Pro, including active Pro trials. Responses are JSON. Exit 0: success; 1: failure; 2: user action required.
52
+ Workspaces retain files for 24h inactivity. Browsers expire after 10m idle / 30m total.
53
+ Login links are included in JSON. Login never reruns code. Use exec --session for the next explicit call.
54
+ Tests and code execute real external actions. No mutations are retried automatically.
55
+ Run commands follow the current attempt; attempts commands target a specific execution. A timeout does not imply no writes occurred.
56
+ `
57
+
58
+ const options = {
59
+ 'env-file': { type: 'string' },
60
+ 'version': { type: 'boolean', short: 'v' },
61
+ 'file': { type: 'string' },
62
+ 'session': { type: 'string' },
63
+ 'attach': { type: 'string', multiple: true },
64
+ 'output-dir': { type: 'string' },
65
+ 'integration': { type: 'string' },
66
+ 'provider': { type: 'string' },
67
+ 'reference': { type: 'string' },
68
+ 'variant': { type: 'string' },
69
+ 'input': { type: 'string' },
70
+ 'output': { type: 'string' },
71
+ 'employee': { type: 'string' },
72
+ 'name': { type: 'string' },
73
+ 'integrations': { type: 'string' },
74
+ 'source': { type: 'string' },
75
+ 'topic': { type: 'string' },
76
+ 'limit': { type: 'string' },
77
+ 'help': { type: 'boolean', short: 'h' },
78
+ }
79
+
80
+ function required(value, label) {
81
+ if (typeof value !== 'string' || !value.trim())
82
+ throw new Error(`${label} is required. Use --help for examples.`)
83
+
84
+ return value.trim()
85
+ }
86
+
87
+ function segment(value, label = 'ID') {
88
+ const result = required(value, label)
89
+
90
+ if (result === '.' || result === '..' || result.includes('/'))
91
+ throw new Error(`${label} must be a single ID`)
92
+
93
+ return encodeURIComponent(result)
94
+ }
95
+
96
+ async function readStructured(file, stdin = process.stdin) {
97
+ required(file, '--file or --input')
98
+ let source
99
+ if (file === '-') {
100
+ const chunks = []
101
+ for await (const chunk of stdin)
102
+ chunks.push(Buffer.from(chunk))
103
+ source = Buffer.concat(chunks).toString('utf8')
104
+ }
105
+ else {
106
+ source = await readFile(file, 'utf8')
107
+ }
108
+
109
+ if (file.toLowerCase().endsWith('.json'))
110
+ return JSON.parse(source)
111
+
112
+ const document = parseDocument(source, { uniqueKeys: true, schema: 'core', merge: false })
113
+
114
+ if (document.errors.length)
115
+ throw new Error(document.errors.map(error => error.message).join('\n'))
116
+
117
+ return document.toJS({ maxAliasCount: 0 })
118
+ }
119
+
120
+ async function definitionBody(file) {
121
+ required(file, '--file')
122
+
123
+ if (file.toLowerCase().endsWith('.json'))
124
+ return { definition: await readStructured(file) }
125
+
126
+ // Keep YAML text intact so the server's canonical parser checks it.
127
+ return { definitionYaml: await readFile(file, 'utf8') }
128
+ }
129
+
130
+ export async function buildRequest(argv, { stdin = process.stdin } = {}) {
131
+ const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true, strict: true })
132
+
133
+ if (values.version)
134
+ return { version: true }
135
+
136
+ if (values.help || !positionals.length)
137
+ return { help: true }
138
+
139
+ const [command, action, id] = positionals
140
+
141
+ const request = { method: 'GET', path: '', query: {}, body: undefined, output: values.output, ...(values['env-file'] ? { envFile: values['env-file'] } : {}) }
142
+
143
+ if (command === 'guide') {
144
+ request.path = '/operator/guide'
145
+
146
+ request.query.topic = values.topic ?? 'overview'
147
+ }
148
+ else if (command === 'list' || command === 'read') {
149
+ request.path = '/operator/resources'
150
+
151
+ request.query = { action: command, path: command === 'read' ? required(action, 'namespace path') : action ?? '/' }
152
+
153
+ if (values.limit)
154
+ request.query.limit = values.limit
155
+ }
156
+ else if (command === 'exec') {
157
+ request.path = '/code/execute'
158
+
159
+ request.method = 'POST'
160
+
161
+ request.body = {
162
+ code: await readFile(required(values.file, '--file'), 'utf8'),
163
+ integrations: values.integrations?.split(',').map(ref => ref.trim()).filter(Boolean) ?? [],
164
+ ...(values.session ? { sessionId: values.session } : {}),
165
+ }
166
+ }
167
+ else if (command === 'sessions' || command === 'session') {
168
+ if (action === 'create' || action === 'list') {
169
+ request.path = '/code/sessions'
170
+
171
+ request.method = action === 'create' ? 'POST' : 'GET'
172
+ }
173
+ else if (action === 'get' || action === 'close') {
174
+ request.path = `/code/sessions/${segment(id)}${action === 'close' ? '/close' : ''}`
175
+
176
+ request.method = action === 'close' ? 'POST' : 'GET'
177
+ }
178
+ }
179
+ else if (command === 'files') {
180
+ const sessionId = action === 'download' ? required(values.session, '--session') : required(id, 'session ID')
181
+
182
+ request.path = `/code/sessions/${segment(sessionId)}/files`
183
+
184
+ if (action === 'upload') {
185
+ request.method = 'POST'
186
+ request.upload = required(values.file, '--file')
187
+ }
188
+ else if (action === 'download') {
189
+ request.path += `/${segment(id)}`
190
+
191
+ request.download = { sessionId, fileId: id, directory: required(values['output-dir'], '--output-dir') }
192
+ }
193
+ else if (action !== 'list') {
194
+ request.path = ''
195
+ }
196
+ }
197
+ else if (command === 'executions' && action === 'get') {
198
+ request.path = `/code/sessions/${segment(values.session, '--session')}/executions/${segment(id)}`
199
+ }
200
+ else if (command === 'browser' && action === 'restart') {
201
+ request.path = `/code/sessions/${segment(id)}/browser/restart`
202
+
203
+ request.method = 'POST'
204
+
205
+ request.body = { integration: required(values.integration, '--integration') }
206
+ }
207
+ else if (command === 'employees') {
208
+ if (action === 'list') {
209
+ request.path = '/employees'
210
+ }
211
+ else if (action === 'create') {
212
+ request.path = '/employees'
213
+
214
+ request.method = 'POST'
215
+
216
+ request.body = await readStructured(values.file)
217
+ }
218
+ else if (['get', 'update', 'delete'].includes(action)) {
219
+ request.path = `/employees/${segment(id)}`
220
+
221
+ request.method = { get: 'GET', update: 'PATCH', delete: 'DELETE' }[action]
222
+
223
+ if (action === 'update')
224
+ request.body = await readStructured(values.file)
225
+ }
226
+ }
227
+ else if (command === 'integrations') {
228
+ const base = '/operator/integrations'
229
+ if (action === 'providers')
230
+ request.path = `${base}/providers`
231
+ else if (action === 'provider')
232
+ request.path = `${base}/providers/${segment(id)}`
233
+ else if (action === 'list')
234
+ request.path = base
235
+ else if (action === 'create') {
236
+ request.path = base
237
+ request.method = 'POST'
238
+ request.body = { providerKey: required(values.provider, '--provider'), label: required(values.name, '--name'), ...(values.reference ? { referenceId: values.reference } : {}) }
239
+ }
240
+ else if (action === 'get')
241
+ request.path = `${base}/${segment(id)}`
242
+ else if (action === 'update') {
243
+ request.path = `${base}/${segment(id)}`
244
+ request.method = 'PATCH'
245
+ request.body = await readStructured(values.file)
246
+ }
247
+ else if (['credentials', 'test', 'enable', 'disable'].includes(action)) {
248
+ request.path = `${base}/${segment(id)}/${action}`
249
+ request.method = 'POST'
250
+ if (action === 'credentials') {
251
+ required(values.file, '--file')
252
+ try {
253
+ request.body = { credentials: await readStructured(values.file, stdin), ...(values.variant ? { variantKey: values.variant } : {}) }
254
+ }
255
+ catch {
256
+ throw new Error('Could not read credentials. Provide a valid JSON or YAML object using --file (or --file - for stdin).')
257
+ }
258
+ }
259
+ }
260
+ }
261
+ else if (command === 'workflows' && action === 'webhook') {
262
+ const routes = { get: ['GET', 'connection'], configure: ['POST', 'connection'], 'rotate-secret': ['POST', 'rotate'] }
263
+ const route = routes[id]
264
+ if (route) {
265
+ request.path = `/workflows/${segment(positionals[3], 'workflow ID')}/webhook/${route[1]}`
266
+ request.method = route[0]
267
+ if (route[0] === 'POST') {
268
+ request.secretOutput = required(values.output, '--output')
269
+ request.output = undefined
270
+ }
271
+ }
272
+ }
273
+ else if (command === 'attempts' && action === 'files') {
274
+ if (id === 'list' || id === 'download') {
275
+ const attemptId = segment(positionals[3], 'attempt ID')
276
+ request.path = `/run-attempts/${attemptId}`
277
+ request.artifactList = true
278
+ if (id === 'download')
279
+ request.artifactDownload = { attemptId, artifactId: required(positionals[4], 'artifact ID'), directory: required(values['output-dir'], '--output-dir') }
280
+ }
281
+ }
282
+ else if (command === 'workflows') {
283
+ if (action === 'list') {
284
+ request.path = '/workflows'
285
+ }
286
+ else if (action === 'create') {
287
+ request.path = '/workflows'
288
+
289
+ request.method = 'POST'
290
+
291
+ request.body = { employeeId: required(values.employee, '--employee'), name: required(values.name, '--name') }
292
+ }
293
+ else {
294
+ const base = `/workflows/${segment(id)}`
295
+
296
+ const routes = {
297
+ 'get': ['GET', '/editor'],
298
+ 'save-draft': ['PUT', '/draft'],
299
+ 'discard-draft': ['DELETE', '/draft'],
300
+ 'validate': ['POST', '/validate'],
301
+ 'export': ['POST', '/yaml'],
302
+ 'publish': ['POST', '/publish'],
303
+ 'enable': ['PATCH', ''],
304
+ 'disable': ['PATCH', ''],
305
+ 'rename': ['PATCH', ''],
306
+ 'delete': ['DELETE', ''],
307
+ }
308
+
309
+ const route = routes[action]
310
+
311
+ if (route) {
312
+ request.method = route[0]
313
+
314
+ request.path = `${base}${route[1]}`
315
+
316
+ if (action === 'save-draft')
317
+ request.body = await definitionBody(values.file)
318
+
319
+ if (action === 'validate')
320
+ request.body = { source: values.source ?? 'draft', ...(values.file ? await definitionBody(values.file) : {}) }
321
+
322
+ if (action === 'export')
323
+ request.body = { source: values.source ?? 'active' }
324
+
325
+ if (action === 'publish')
326
+ request.body = {}
327
+
328
+ if (action === 'enable' || action === 'disable')
329
+ request.body = { enabled: action === 'enable' }
330
+
331
+ if (action === 'rename')
332
+ request.body = { name: required(values.name, '--name') }
333
+ }
334
+ }
335
+ }
336
+ else if (command === 'runs') {
337
+ if (action === 'list') {
338
+ request.path = '/runs'
339
+
340
+ if (values.limit)
341
+ request.query.limit = values.limit
342
+ }
343
+ else if (action === 'test' || action === 'start') {
344
+ request.path = '/runs'
345
+
346
+ request.method = 'POST'
347
+
348
+ request.body = {
349
+ workflowId: required(id, 'workflow ID'),
350
+ useDraft: action === 'test',
351
+ startAsync: true,
352
+ triggerData: values.input ? await readStructured(values.input) : {},
353
+ }
354
+ }
355
+ else if (action === 'get' || action === 'attempts') {
356
+ request.path = `/runs/${segment(id)}${action === 'get' ? '' : '/attempts'}`
357
+ }
358
+ else if (['events', 'definition', 'cancel'].includes(action)) {
359
+ request.path = `/runs/${segment(id)}`
360
+
361
+ request.resolveAttemptAction = action
362
+ }
363
+ }
364
+ else if (command === 'attempts' && ['get', 'events', 'definition', 'cancel'].includes(action)) {
365
+ request.path = `/run-attempts/${segment(id)}${action === 'get' ? '' : `/${action}`}`
366
+
367
+ if (action === 'cancel')
368
+ request.method = 'POST'
369
+ }
370
+
371
+ if (!request.path)
372
+ throw new Error('Unknown command. Use --help for supported commands.')
373
+
374
+ const maxPositionals = command === 'integrations' && ['providers', 'list', 'create'].includes(action) ? 2 : command === 'workflows' && action === 'webhook' ? 4 : command === 'attempts' && action === 'files' ? (id === 'download' ? 5 : 4) : ['guide', 'exec'].includes(command) ? 1 : ['list', 'read'].includes(command) ? 2 : 3
375
+
376
+ if (positionals.length > maxPositionals)
377
+ throw new Error('Unexpected positional argument. Use --help for command syntax.')
378
+
379
+ if (command === 'exec' && values.attach?.length)
380
+ request.attach = values.attach
381
+
382
+ if (command === 'exec' && values['output-dir'])
383
+ request.outputDir = values['output-dir']
384
+
385
+ return request
386
+ }
387
+
388
+ // Reject separators and control characters before creating any local file.
389
+ // eslint-disable-next-line no-control-regex
390
+ const UNSAFE_DOWNLOAD_NAME = /[\\/\x00-\x1F\x7F]/
391
+ const WINDOWS_FILENAME_CHARS = /[<>:"|?*]/g
392
+
393
+ export function safeDownloadName(name) {
394
+ if (typeof name !== 'string' || !name || name === '.' || name === '..' || UNSAFE_DOWNLOAD_NAME.test(name))
395
+ throw new Error('Unsafe output filename')
396
+
397
+ return name.replace(WINDOWS_FILENAME_CHARS, '_')
398
+ }
399
+
400
+ function credentialStrings(value) {
401
+ if (typeof value === 'string')
402
+ return value ? [value] : []
403
+ if (value && typeof value === 'object')
404
+ return Object.values(value).flatMap(credentialStrings)
405
+ return []
406
+ }
407
+
408
+ function redactString(value, secrets) {
409
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length))
410
+ value = value.replaceAll(secret, '[REDACTED]')
411
+ return value
412
+ }
413
+
414
+ function redactResponse(value, secrets) {
415
+ if (typeof value === 'string')
416
+ return redactString(value, secrets)
417
+ if (Array.isArray(value))
418
+ return value.map(item => redactResponse(item, secrets))
419
+ if (value && typeof value === 'object')
420
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [redactString(key, secrets), redactResponse(item, secrets)]))
421
+ return value
422
+ }
423
+
424
+ export async function runCli(argv, { env = process.env, fetchImpl = fetch, stdout = process.stdout, stderr = process.stderr, stdin = process.stdin } = {}) {
425
+ let recovery
426
+ let request
427
+ let secretHandle
428
+ let secretWritten = false
429
+ try {
430
+ request = await buildRequest(argv, { stdin })
431
+
432
+ if (request.version) {
433
+ stdout.write(`${packageJson.version}\n`)
434
+
435
+ return 0
436
+ }
437
+
438
+ if (request.help) {
439
+ stdout.write(HELP)
440
+
441
+ return 0
442
+ }
443
+
444
+ if (request.envFile)
445
+ env = { ...parseEnv(await readFile(request.envFile, 'utf8')), ...env }
446
+
447
+ const base = new URL(env.GONDO_API_URL || 'https://runtime.gondo.ai')
448
+
449
+ if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))
450
+ throw new Error('GONDO_API_URL must use HTTPS, or HTTP on localhost for development')
451
+
452
+ if (base.username || base.password || base.search || base.hash)
453
+ throw new Error('GONDO_API_URL must not include credentials, a query, or a fragment')
454
+
455
+ let account = env.GONDO_ACCOUNT_ID === undefined ? undefined : segment(env.GONDO_ACCOUNT_ID, 'GONDO_ACCOUNT_ID')
456
+
457
+ const key = required(env.GONDO_API_KEY, 'GONDO_API_KEY')
458
+
459
+ const send = async (next, { accountScoped = true } = {}) => {
460
+ const url = new URL(accountScoped ? `/api/accounts/${account}${next.path}` : next.path, base)
461
+
462
+ for (const [name, value] of Object.entries(next.query ?? {}))
463
+ url.searchParams.set(name, value)
464
+
465
+ let uploadStream
466
+
467
+ let uploadHeaders = {}
468
+
469
+ if (next.upload) {
470
+ const size = (await stat(next.upload)).size
471
+
472
+ if (!(await stat(next.upload)).isFile())
473
+ throw new Error('Only regular files can be uploaded')
474
+
475
+ uploadStream = createReadStream(next.upload)
476
+
477
+ uploadHeaders = { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), 'X-File-Name': encodeURIComponent(basename(next.upload)) }
478
+ }
479
+
480
+ let response
481
+
482
+ try {
483
+ response = await fetchImpl(url, {
484
+ method: next.method,
485
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', ...uploadHeaders },
486
+ ...(uploadStream ? { body: uploadStream, duplex: 'half' } : next.body !== undefined ? { body: JSON.stringify(next.body) } : {}),
487
+ redirect: 'error',
488
+ signal: AbortSignal.timeout(120000),
489
+ })
490
+ }
491
+ finally {
492
+ uploadStream?.destroy()
493
+ }
494
+
495
+ if (next.binary && response.ok)
496
+ return { response }
497
+
498
+ const text = await response.text()
499
+ let result
500
+
501
+ try {
502
+ result = text ? redactResponse(JSON.parse(text), [key, ...credentialStrings(request.body?.credentials)]) : null
503
+ }
504
+ catch {
505
+ throw new Error(`Runtime returned non-JSON (HTTP ${response.status}). Check GONDO_API_URL points to the runtime.`)
506
+ }
507
+
508
+ return { response, result }
509
+ }
510
+
511
+ const failed = ({ response, result }) => {
512
+ if (response.ok && result?.success !== false && result?.ok !== false)
513
+ return false
514
+
515
+ stderr.write(`${JSON.stringify({ status: response.status, ...result })}\n`)
516
+
517
+ return true
518
+ }
519
+
520
+ if (request.secretOutput)
521
+ secretHandle = await open(request.secretOutput, 'wx', 0o600)
522
+
523
+ if (!account) {
524
+ const identity = await send({ path: '/api/operator/me', method: 'GET' }, { accountScoped: false })
525
+ if (failed(identity))
526
+ return 1
527
+ account = segment(identity.result?.accountId, 'API key account ID')
528
+ }
529
+
530
+ const download = async (sessionId, file, directory, fetchFile) => {
531
+ const declaredSize = file.sizeBytes ?? undefined
532
+ if (declaredSize !== undefined && (!Number.isSafeInteger(declaredSize) || declaredSize < 0))
533
+ throw new Error('Invalid file size metadata')
534
+ const name = safeDownloadName(file.name)
535
+
536
+ await mkdir(directory, { recursive: true })
537
+
538
+ const target = resolve(directory, name)
539
+
540
+ // Exclusive creation also rejects an existing symlink. Never overwrite an earlier output.
541
+ const handle = await open(target, 'wx', 0o600)
542
+
543
+ let done = false
544
+
545
+ try {
546
+ const received = fetchFile ? await fetchFile() : await send({ path: `/code/sessions/${segment(sessionId)}/files/${segment(file.id)}`, method: 'GET', binary: true })
547
+
548
+ if (!received.response.ok)
549
+ throw new Error(`File download failed (HTTP ${received.response.status})`)
550
+
551
+ const hash = createHash('sha256')
552
+
553
+ let size = 0
554
+
555
+ const check = new Transform({ transform(chunk, _encoding, callback) {
556
+ size += chunk.length
557
+
558
+ if (size > (declaredSize ?? 512 * 1024 * 1024))
559
+ return callback(new Error('Download exceeded its declared size'))
560
+
561
+ hash.update(chunk)
562
+ callback(null, chunk)
563
+ } })
564
+
565
+ await pipeline(Readable.fromWeb(received.response.body), check, handle.createWriteStream())
566
+
567
+ if ((declaredSize !== undefined && size !== declaredSize) || (file.sha256 && hash.digest('hex') !== file.sha256))
568
+ throw new Error('Downloaded file failed integrity verification')
569
+
570
+ done = true
571
+
572
+ return { fileId: file.id, savedTo: target }
573
+ }
574
+ finally {
575
+ await handle.close()
576
+ if (!done) {
577
+ await unlink(target).catch(() => {
578
+ })
579
+ }
580
+ }
581
+ }
582
+
583
+ if (request.download) {
584
+ const d = request.download
585
+
586
+ const listing = await send({ path: `/code/sessions/${segment(d.sessionId)}/files`, method: 'GET' })
587
+
588
+ if (failed(listing))
589
+ return 1
590
+
591
+ const file = listing.result.files.find(f => f.id === d.fileId)
592
+
593
+ if (!file)
594
+ throw new Error('File not found in this session')
595
+
596
+ stdout.write(`${JSON.stringify(await download(d.sessionId, file, d.directory))}\n`)
597
+
598
+ return 0
599
+ }
600
+
601
+ if (request.attach?.length) {
602
+ if (!request.body.sessionId) {
603
+ const created = await send({ path: '/code/sessions', method: 'POST' })
604
+
605
+ if (failed(created))
606
+ return 1
607
+
608
+ request.body.sessionId = created.result.id
609
+ }
610
+
611
+ request.body.fileIds = []
612
+
613
+ for (const file of request.attach) {
614
+ const uploaded = await send({ path: `/code/sessions/${segment(request.body.sessionId)}/files`, method: 'POST', upload: file })
615
+
616
+ if (failed(uploaded))
617
+ return 1
618
+
619
+ request.body.fileIds.push(uploaded.result.id)
620
+ }
621
+ }
622
+
623
+ let received = await send(request)
624
+ if (request.path === '/code/execute')
625
+ recovery = { sessionId: received.result?.sessionId, executionId: received.result?.executionId }
626
+
627
+ if (request.outputDir && received.result?.files?.length) {
628
+ received.result.downloads = []
629
+
630
+ for (const file of received.result.files) {
631
+ if (file.executionId !== received.result.executionId)
632
+ throw new Error('Runtime returned an output from a different execution')
633
+
634
+ received.result.downloads.push(await download(received.result.sessionId, file, request.outputDir))
635
+ }
636
+ }
637
+
638
+ if (received.result?.requiredAction) {
639
+ stdout.write(`${JSON.stringify(received.result, null, 2)}\n`)
640
+
641
+ return 2
642
+ }
643
+
644
+ if (failed(received))
645
+ return 1
646
+
647
+ if (request.resolveAttemptAction) {
648
+ // Resolve once, then pin this operation to that execution even if a retry
649
+ // becomes current in the meantime. Never retry cancellation automatically.
650
+ const attemptId = segment(received.result?.currentAttemptId, 'current attempt ID')
651
+
652
+ received = await send({
653
+ path: `/run-attempts/${attemptId}/${request.resolveAttemptAction}`,
654
+ method: request.resolveAttemptAction === 'cancel' ? 'POST' : 'GET',
655
+ })
656
+
657
+ if (failed(received))
658
+ return 1
659
+ }
660
+
661
+ if (request.secretOutput) {
662
+ await secretHandle.writeFile(`${JSON.stringify(received.result, null, 2)}\n`, 'utf8')
663
+ await secretHandle.sync()
664
+ secretWritten = true
665
+ stdout.write(`${JSON.stringify({ savedTo: resolve(request.secretOutput), configured: received.result?.configured, secretIssued: Boolean(received.result?.secret) })}\n`)
666
+ return 0
667
+ }
668
+
669
+ if (request.artifactList) {
670
+ const files = received.result?.fileArtifacts ?? []
671
+ if (request.artifactDownload) {
672
+ const d = request.artifactDownload
673
+ const file = files.find(item => item.id === d.artifactId)
674
+ if (!file)
675
+ throw new Error('File not found in this attempt')
676
+ const saved = await download(null, file, d.directory, async () => {
677
+ const signed = await send({ path: `/run-attempts/${d.attemptId}/files/${segment(d.artifactId)}/download-url`, method: 'GET' })
678
+ if (failed(signed))
679
+ throw new Error('Could not obtain file download URL')
680
+ const url = new URL(signed.result?.sasUrl)
681
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash)
682
+ throw new Error('Invalid storage download URL')
683
+ // Storage is a different origin. Never forward the Gondo Authorization header.
684
+ try {
685
+ return { response: await fetchImpl(url, { method: 'GET', redirect: 'error', signal: AbortSignal.timeout(120000) }) }
686
+ }
687
+ catch {
688
+ throw new Error('Storage download failed. Retry the download to obtain a fresh URL.')
689
+ }
690
+ })
691
+ stdout.write(`${JSON.stringify(saved)}\n`)
692
+ return 0
693
+ }
694
+ received.result = { files: files.map(({ id, name, mime, sizeBytes, sha256 }) => ({ id, name, mime, sizeBytes, sha256 })) }
695
+ }
696
+
697
+ const { result } = received
698
+
699
+ if (request.output) {
700
+ const content = typeof result?.yaml === 'string' ? result.yaml : `${JSON.stringify(result, null, 2)}\n`
701
+
702
+ await writeFile(request.output, content, { encoding: 'utf8', mode: 0o600 })
703
+
704
+ stdout.write(`${JSON.stringify({ savedTo: resolve(request.output) })}\n`)
705
+ }
706
+ else {
707
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`)
708
+ }
709
+
710
+ return 0
711
+ }
712
+ catch (error) {
713
+ // Do not dump request objects, environment variables, or authorization headers.
714
+ let message = error instanceof Error ? error.message : 'Command failed'
715
+ const key = env.GONDO_API_KEY
716
+ message = redactString(message, [...(key ? [key] : []), ...credentialStrings(request?.body?.credentials)])
717
+ if (request?.secretOutput && secretHandle && !secretWritten)
718
+ message += ' Webhook state may have changed; inspect it before explicitly rotating again. No automatic retry was performed.'
719
+
720
+ stderr.write(`${JSON.stringify({ error: message, ...(request?.path === '/code/execute' ? { sessionId: request.body?.sessionId, ...recovery, recovery: 'Inspect the session and execution before continuing; do not repeat this execution automatically.' } : {}) })}\n`)
721
+
722
+ return 1
723
+ }
724
+ finally {
725
+ if (secretHandle) {
726
+ await secretHandle.close()
727
+ if (!secretWritten)
728
+ await unlink(request.secretOutput).catch(() => {})
729
+ }
730
+ }
731
+ }