@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 +118 -0
- package/bin/gondo.mjs +4 -0
- package/gondo.env.example +1 -0
- package/package.json +39 -0
- package/src/cli.mjs +731 -0
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 @@
|
|
|
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
|
+
}
|