zoodealio 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +258 -2
- package/bin/zoodealio.js +11 -0
- package/package.json +37 -4
- package/src/args.js +94 -0
- package/src/cli.js +168 -0
- package/src/commands.js +221 -0
- package/src/credentials.js +87 -0
- package/src/errors.js +29 -0
- package/src/http.js +126 -0
- package/src/input.js +43 -0
- package/src/login.js +136 -0
- package/src/mcp.js +107 -0
package/README.md
CHANGED
|
@@ -1,3 +1,259 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Zoodealio CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`zoodealio` is a command line wrapper over the partner API (`/openapi`). It prints JSON,
|
|
4
|
+
takes JSON bodies, and describes itself, so people, scripts and AI agents can drive the
|
|
5
|
+
same contract as the Zoodealio MCP server (`Mcp/OpenApiMcpTools.cs`) and the
|
|
6
|
+
[Zapier app](../zapier/README.md). It adds no API surface: every command is one
|
|
7
|
+
documented `/openapi` request, and bodies pass through unchanged.
|
|
8
|
+
|
|
9
|
+
It has no runtime dependencies and needs Node 22 or newer.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g zoodealio
|
|
15
|
+
zoodealio login
|
|
16
|
+
zoodealio whoami
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or run it without installing: `npx zoodealio whoami` (after a `zoodealio login` elsewhere, or with
|
|
20
|
+
`ZOODEALIO_API_KEY` set). From a checkout of this repo, `node integrations/cli/bin/zoodealio.js`
|
|
21
|
+
runs the working copy.
|
|
22
|
+
|
|
23
|
+
## Authenticate
|
|
24
|
+
|
|
25
|
+
### Sign in (the default)
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
zoodealio login # opens your browser; approve "Sign in Zoodealio CLI on <device>"
|
|
29
|
+
zoodealio login --no-browser # print the URL to stderr instead (SSH, containers)
|
|
30
|
+
zoodealio login --name work-laptop # device name shown at sign-in (default: this host)
|
|
31
|
+
zoodealio logout # revokes this machine's key and forgets it
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`login` binds a loopback port, sends you to Zoodealio AI to approve, and exchanges the one-time
|
|
35
|
+
code (with PKCE) for a personal API key named `CLI – <device>`. It waits up to 5 minutes. It prints
|
|
36
|
+
`{ok, email, name, keyPrefix, origin}` and never the key.
|
|
37
|
+
|
|
38
|
+
The key is stored per API origin in `$XDG_CONFIG_HOME/zoodealio/credentials.json`
|
|
39
|
+
(default `~/.config/zoodealio/credentials.json`; `%APPDATA%\zoodealio\credentials.json` on Windows),
|
|
40
|
+
written atomically with mode `0600` in a `0700` directory. Keys are per environment:
|
|
41
|
+
`zoodealio login --env dev` stores a separate login. `ZOODEALIO_WEB_URL` overrides the sign-in
|
|
42
|
+
page origin the way `ZOODEALIO_API_URL` overrides the API origin.
|
|
43
|
+
|
|
44
|
+
### API key for CI and servers
|
|
45
|
+
|
|
46
|
+
Copy a personal API key from **Settings → API** in Zoodealio AI and export it:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
export ZOODEALIO_API_KEY=zoo_...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`ZOODEALIO_API_KEY` wins over a stored login. If neither is present the CLI exits 3 with
|
|
53
|
+
`Not signed in. Run zoodealio login, or set ZOODEALIO_API_KEY.` and sends nothing.
|
|
54
|
+
A 401 on a stored login means it was revoked or expired; run `zoodealio login` again.
|
|
55
|
+
|
|
56
|
+
The key is sent only as the `ApiKey` header. It is never printed: errors, `--dry-run` output and
|
|
57
|
+
`whoami` show it as `zoo_…<last4>` (the one exception is `keys create`, whose purpose is to show a
|
|
58
|
+
new key once). Redirects are refused so the header cannot be forwarded. The key keeps its owner's
|
|
59
|
+
workspace and property scope. Every request sends `User-Agent: zoodealio-cli/<version>`.
|
|
60
|
+
|
|
61
|
+
`zoodealio whoami` confirms the key works and shows the target origin, `source` (`login` or
|
|
62
|
+
`env`) and, for a login, the `email`.
|
|
63
|
+
|
|
64
|
+
### Keys
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
zoodealio keys list # your keys; "current" marks the one in use
|
|
68
|
+
zoodealio keys create --name ci # prints the raw key once
|
|
69
|
+
zoodealio keys revoke <id> --yes
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Key management needs a key created by `zoodealio login`; keys made with `keys create` or in the web
|
|
73
|
+
app cannot manage keys.
|
|
74
|
+
|
|
75
|
+
### MCP
|
|
76
|
+
|
|
77
|
+
`zoodealio mcp` is a stdio MCP server that forwards each JSON-RPC message to the hosted
|
|
78
|
+
`/openapi/mcp` endpoint with your key, so MCP clients need no HTTP or header setup.
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
claude mcp add zoodealio -- zoodealio mcp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Cursor (`.cursor/mcp.json`) or VS Code (`.vscode/mcp.json`, under `servers`):
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "mcpServers": { "zoodealio": { "command": "zoodealio", "args": ["mcp"] } } }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run `zoodealio login` first (or put `ZOODEALIO_API_KEY` in the client's `env`). Stdout carries only
|
|
91
|
+
JSON-RPC; logs go to stderr.
|
|
92
|
+
|
|
93
|
+
## Environments
|
|
94
|
+
|
|
95
|
+
| Selection | API origin |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| default | `https://api.zoodealio.ai` |
|
|
98
|
+
| `--env dev` | `https://dev.api.zoodealio.ai` |
|
|
99
|
+
| `ZOODEALIO_API_URL=http://localhost:5256` | that origin (overrides both) |
|
|
100
|
+
|
|
101
|
+
Keys are per environment. Use a local API URL only for a local API; the port above is an
|
|
102
|
+
example, use whichever yours runs on.
|
|
103
|
+
|
|
104
|
+
## Output and errors
|
|
105
|
+
|
|
106
|
+
stdout carries JSON and nothing else: pretty-printed on a terminal, compact when piped
|
|
107
|
+
(`--pretty` forces pretty). An empty or 204 response prints `null`. Help text is the only
|
|
108
|
+
non-JSON stdout.
|
|
109
|
+
|
|
110
|
+
Failures print one JSON object on stderr and exit non-zero:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{"error":{"status":404,"code":"not_found","title":"Not Found","detail":"..."}}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`title` and `detail` come from the API's problem response when present. `status` is `null`
|
|
117
|
+
for usage and network errors. A 429 adds `retryAfter` in seconds.
|
|
118
|
+
|
|
119
|
+
| Exit | Meaning | `code` |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| 0 | Success | |
|
|
122
|
+
| 1 | Other API or network error; login cancelled or timed out | `http_error`, `network_error`, `login_cancelled`, `login_timeout` |
|
|
123
|
+
| 2 | Usage: unknown command or flag, missing argument, bad ID or JSON, missing `--yes`, or HTTP 400 | `usage`, `bad_request` |
|
|
124
|
+
| 3 | Not signed in, or not authenticated or allowed (401, 403) | `unauthorized`, `forbidden` |
|
|
125
|
+
| 4 | Not found (404) | `not_found` |
|
|
126
|
+
| 5 | Conflict, precondition or validation failure (409, 412, 422) | `conflict`, `precondition_failed`, `validation_failed` |
|
|
127
|
+
| 6 | Rate limited (429) | `rate_limited` |
|
|
128
|
+
|
|
129
|
+
Route IDs are GUIDs and are checked before any request is made.
|
|
130
|
+
|
|
131
|
+
## Global flags
|
|
132
|
+
|
|
133
|
+
| Flag | Effect |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `--env prod\|dev` | Pick the environment (default `prod`) |
|
|
136
|
+
| `--pretty` | Pretty-print JSON even when piped |
|
|
137
|
+
| `--dry-run` | Print `{method, url, headers, body}` with the key redacted and send nothing; works without a key |
|
|
138
|
+
| `--yes` | Confirm a destructive command (not needed with `--dry-run`) |
|
|
139
|
+
| `--help` | Help for the CLI or one command |
|
|
140
|
+
|
|
141
|
+
Write bodies come from `--body '<json>'`, `--body @file.json` or `--body -` (stdin). Where
|
|
142
|
+
a body is trivial, convenience flags build it instead; use one or the other, not both.
|
|
143
|
+
List commands accept `--page` and `--page-size`. `--if-match <etag>` sends optimistic
|
|
144
|
+
concurrency on `properties update`, `offers accept` and `offers request-details`.
|
|
145
|
+
|
|
146
|
+
## Commands
|
|
147
|
+
|
|
148
|
+
`zoodealio commands` prints the authoritative catalogue. In short:
|
|
149
|
+
|
|
150
|
+
| Command | Request | Notes |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `properties list` | GET Properties | `--search`, `--state` |
|
|
153
|
+
| `properties get <id>` | GET Properties/{id} | |
|
|
154
|
+
| `properties create` | POST Properties | `--body` |
|
|
155
|
+
| `properties update <id>` | PUT Properties/{id} | `--body`, `--if-match`; replaces wholesale |
|
|
156
|
+
| `properties photos list\|add\|delete` | photos | `add`: `--url` (repeatable) or `--body`; `delete <id> <photoId>` needs `--yes` |
|
|
157
|
+
| `properties documents list\|add\|delete` | documents | same shape as photos |
|
|
158
|
+
| `properties import-status <id> <importId>` | GET imports/{importId} | Photo and document imports |
|
|
159
|
+
| `offers list` | GET Offers | `--property` required, `--status` |
|
|
160
|
+
| `offers get <id>`, `offers history <id>` | GET | |
|
|
161
|
+
| `offers accept <id>` | POST accept | `--yes` required; `--attributes <json\|@file>`, `--if-match` |
|
|
162
|
+
| `offers request-details <id>` | POST request-details | `--if-match` |
|
|
163
|
+
| `transactions get <id>` | GET Transactions/{id} | |
|
|
164
|
+
| `transactions active` | GET Transactions/active | `--property` required |
|
|
165
|
+
| `comping status <transactionId>` | GET Transactions/{id}/comping | |
|
|
166
|
+
| `team members`, `team get <id>`, `teams list` | GET Team | `--search` |
|
|
167
|
+
| `team invite` | POST Team/members | `--email` (+ `--first-name`, `--last-name`, `--team`, `--admin-team`) or `--body` |
|
|
168
|
+
| `team remove <id>` | DELETE Team/members/{id} | `--yes` required |
|
|
169
|
+
| `webhooks event-types\|list\|deliveries` | GET Webhooks | `deliveries`: `--subscription`, `--status` |
|
|
170
|
+
| `webhooks create` | POST Webhooks | `--url` and `--event` (repeatable) or `--body` |
|
|
171
|
+
| `webhooks update <id>` | PUT Webhooks/{id} | `--body` (`endpointUrl`, `eventTypes`, `isActive`) |
|
|
172
|
+
| `webhooks rotate <id>`, `webhooks delete <id>` | POST, DELETE | `--yes` required |
|
|
173
|
+
| `login`, `logout` | | Browser sign-in and sign-out; `login` takes `--no-browser`, `--name` |
|
|
174
|
+
| `keys list\|create\|revoke` | GET, POST, DELETE Keys | `create`: `--name`; `revoke <id>` needs `--yes`; needs a `login` key |
|
|
175
|
+
| `mcp` | POST `/openapi/mcp` | stdio MCP bridge |
|
|
176
|
+
| `whoami` | | Key check; prints `{ok, origin, key, source, email?}` |
|
|
177
|
+
| `openapi` | GET `/_openapi/partner.json` | Partner OpenAPI document; no key needed |
|
|
178
|
+
| `api <METHOD> <path>` | any `/openapi/...` | `--query k=v` (repeatable), `--body`; `DELETE` needs `--yes` |
|
|
179
|
+
| `commands` | | Command catalogue as JSON |
|
|
180
|
+
|
|
181
|
+
Destructive commands (`keys revoke`, `offers accept`, `team remove`, `properties photos delete`,
|
|
182
|
+
`properties documents delete`, `webhooks rotate`, `webhooks delete`, and `api ... DELETE`)
|
|
183
|
+
refuse to run without `--yes`, exit 2, and send nothing. `--dry-run` previews them
|
|
184
|
+
without `--yes`, since it sends nothing either.
|
|
185
|
+
|
|
186
|
+
`api` accepts `Properties` or `/openapi/Properties`. Paths that are absolute URLs, contain
|
|
187
|
+
`..`, or fall outside `/openapi/` are rejected.
|
|
188
|
+
|
|
189
|
+
## Examples
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
# Properties: search, then import photos from URLs
|
|
193
|
+
zoodealio properties list --search "Elm St" --state AZ
|
|
194
|
+
zoodealio properties photos add <propertyId> --url https://example.com/front.jpg
|
|
195
|
+
zoodealio properties import-status <propertyId> <importId>
|
|
196
|
+
|
|
197
|
+
# Offers: list for a property, preview an accept, then accept
|
|
198
|
+
zoodealio offers list --property <propertyId>
|
|
199
|
+
zoodealio offers accept <offerId> --dry-run
|
|
200
|
+
zoodealio offers accept <offerId> --yes
|
|
201
|
+
|
|
202
|
+
# Transactions and comping
|
|
203
|
+
zoodealio transactions active --property <propertyId>
|
|
204
|
+
zoodealio comping status <transactionId>
|
|
205
|
+
|
|
206
|
+
# Team
|
|
207
|
+
zoodealio team invite --email new@example.com --first-name Ada --team <teamId>
|
|
208
|
+
|
|
209
|
+
# Webhooks: create a subscription (the signing secret is shown once)
|
|
210
|
+
zoodealio webhooks create --url https://example.com/hooks --event offer.created --event comping.completed
|
|
211
|
+
|
|
212
|
+
# Anything else under /openapi
|
|
213
|
+
zoodealio api GET Properties --query pageSize=5
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## For AI agents
|
|
217
|
+
|
|
218
|
+
- Start with `zoodealio commands`. It lists every command with its method, path, arguments,
|
|
219
|
+
flags and a `destructive` marker, generated from the same table the parser uses.
|
|
220
|
+
- stdout is JSON only and errors are JSON on stderr, so parse stdout on exit 0 and
|
|
221
|
+
stderr otherwise. Branch on the exit code (for example back off on 6 using `retryAfter`).
|
|
222
|
+
- Run writes with `--dry-run` first and check the URL and body, then run them again
|
|
223
|
+
without it.
|
|
224
|
+
- `--yes` is a gate for explicit human intent. Pass it only when the person asked for that
|
|
225
|
+
destructive action (accepting an offer, removing a member, deleting or rotating a
|
|
226
|
+
webhook); do not add it to make an error go away.
|
|
227
|
+
- For request body shapes run `zoodealio openapi`, which prints the partner OpenAPI document.
|
|
228
|
+
- Prefer webhooks, or polling `comping status` and `import-status`, to long waits. See
|
|
229
|
+
[OPEN_API_WEBHOOKS.md](../../docs/OPEN_API_WEBHOOKS.md) for events, signatures and delivery.
|
|
230
|
+
|
|
231
|
+
## Limits
|
|
232
|
+
|
|
233
|
+
- No multipart uploads of local files. Photos and documents are imported from URLs only
|
|
234
|
+
(`photos add --url`); `api` cannot send multipart either.
|
|
235
|
+
- No keychain or interactive prompts. Credentials are `ZOODEALIO_API_KEY` or the
|
|
236
|
+
`credentials.json` written by `zoodealio login`.
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
cd integrations/cli
|
|
242
|
+
npm install
|
|
243
|
+
npm run check # eslint, tsc --noEmit (checkJs), node --test
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Releasing
|
|
247
|
+
|
|
248
|
+
npm no longer lets a token that bypasses 2FA publish directly, so releases are staged and then
|
|
249
|
+
approved by a maintainer with 2FA:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
cd integrations/cli
|
|
253
|
+
npm version <patch|minor|major> --no-git-tag-version # in a PR, then merge
|
|
254
|
+
npm run check
|
|
255
|
+
npm stage publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Then approve it on npmjs.com (avatar → Staged Packages → Approve). A version with a `-` suffix
|
|
259
|
+
(`0.2.0-beta.1`) is staged with `--tag next` so `latest` stays stable.
|
package/bin/zoodealio.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zoodealio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Zoodealio command line tool: the partner API as JSON, built for people, scripts and AI agents",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Zoodealio-admin/zoodealioai-services.git",
|
|
9
|
+
"directory": "integrations/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://zoodealio.ai/developers",
|
|
12
|
+
"bin": {
|
|
13
|
+
"zoodealio": "bin/zoodealio.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test --test-isolation=none test/*.test.js",
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"check": "npm run lint && npm run typecheck && npm test"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@eslint/js": "9.39.1",
|
|
34
|
+
"@types/node": "22.19.0",
|
|
35
|
+
"eslint": "9.39.1",
|
|
36
|
+
"globals": "16.5.0",
|
|
37
|
+
"typescript": "5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const { parseArgs } = require('node:util');
|
|
2
|
+
const { usage } = require('./errors');
|
|
3
|
+
const { COMMANDS, GLOBAL_FLAGS, GUID, helpText, usageLine } = require('./commands');
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('./commands').Command} Command */
|
|
6
|
+
|
|
7
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
8
|
+
|
|
9
|
+
/** One parseArgs option set covering every flag any command knows; per-command validity is checked after. */
|
|
10
|
+
const OPTIONS = (() => {
|
|
11
|
+
/** @type {Record<string, {type: 'string' | 'boolean', multiple?: boolean}>} */
|
|
12
|
+
const options = {};
|
|
13
|
+
for (const f of [...GLOBAL_FLAGS, ...COMMANDS.flatMap((c) => c.flags)]) {
|
|
14
|
+
options[f.name] = f.type === 'string' ? { type: 'string', multiple: true } : { type: 'boolean' };
|
|
15
|
+
}
|
|
16
|
+
return options;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string[]} argv
|
|
21
|
+
* @returns {{help: string} | {command: Command, args: Record<string, string>, flags: Record<string, any>}}
|
|
22
|
+
*/
|
|
23
|
+
function parse(argv) {
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw usage(/** @type {Error} */ (err).message);
|
|
29
|
+
}
|
|
30
|
+
const { values, positionals } = parsed;
|
|
31
|
+
if (values.help) return { help: helpText(positionals) };
|
|
32
|
+
if (positionals.length === 0) throw usage('missing command; run `zoodealio --help`');
|
|
33
|
+
|
|
34
|
+
let command;
|
|
35
|
+
for (const c of COMMANDS) {
|
|
36
|
+
const words = c.name.split(' ');
|
|
37
|
+
if (words.every((w, i) => positionals[i] === w) && (!command || words.length > command.name.split(' ').length)) command = c;
|
|
38
|
+
}
|
|
39
|
+
if (!command) throw usage(`unknown command: ${positionals.join(' ')}`);
|
|
40
|
+
|
|
41
|
+
const rest = positionals.slice(command.name.split(' ').length);
|
|
42
|
+
if (rest.length !== command.args.length) throw usage(`expected: ${usageLine(command)}`);
|
|
43
|
+
/** @type {Record<string, string>} */
|
|
44
|
+
const args = {};
|
|
45
|
+
command.args.forEach((a, i) => {
|
|
46
|
+
if (a.guid && !GUID.test(rest[i])) throw usage(`<${a.name}> must be a GUID, got: ${rest[i]}`);
|
|
47
|
+
args[a.name] = rest[i];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const allowed = new Map([...GLOBAL_FLAGS, ...command.flags].map((f) => [f.name, f]));
|
|
51
|
+
/** @type {Record<string, any>} */
|
|
52
|
+
const flags = {};
|
|
53
|
+
for (const [name, value] of Object.entries(values)) {
|
|
54
|
+
const spec = allowed.get(name);
|
|
55
|
+
if (!spec) throw usage(`--${name} is not valid for \`${command.name}\``);
|
|
56
|
+
if (spec.type === 'boolean') flags[name] = value;
|
|
57
|
+
else if (spec.multiple) flags[name] = value;
|
|
58
|
+
else if (/** @type {string[]} */ (value).length > 1) throw usage(`--${name} may be given only once`);
|
|
59
|
+
else flags[name] = /** @type {string[]} */ (value)[0];
|
|
60
|
+
}
|
|
61
|
+
for (const f of command.flags) if (f.required && flags[f.name] === undefined) throw usage(`missing required flag --${f.name}`);
|
|
62
|
+
return { command, args, flags };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validates an `api` escape-hatch path and returns it rooted at /openapi/.
|
|
67
|
+
* @param {string} input
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function normalizeApiPath(input) {
|
|
71
|
+
const bad = () => usage(`api path must be under /openapi/ (got: ${input})`);
|
|
72
|
+
let decoded = input;
|
|
73
|
+
for (let i = 0; i < 4; i++) {
|
|
74
|
+
if (/(^|[\\/])\.\.?([\\/]|$)/.test(decoded) || /[\\?#]|:\/\/|^\/\//.test(decoded) || [...decoded].some((ch) => ch.charCodeAt(0) < 32)) throw bad();
|
|
75
|
+
let next;
|
|
76
|
+
try {
|
|
77
|
+
next = decodeURIComponent(decoded);
|
|
78
|
+
} catch {
|
|
79
|
+
throw bad();
|
|
80
|
+
}
|
|
81
|
+
if (next === decoded) break;
|
|
82
|
+
decoded = next;
|
|
83
|
+
}
|
|
84
|
+
const trimmed = input.replace(/^\/+/, '');
|
|
85
|
+
const first = trimmed.split('/')[0].toLowerCase();
|
|
86
|
+
if (first === 'openapi') {
|
|
87
|
+
if (trimmed.split('/').length < 2 || !trimmed.split('/')[1]) throw bad();
|
|
88
|
+
return `/openapi/${trimmed.slice(first.length + 1)}`;
|
|
89
|
+
}
|
|
90
|
+
if (input.startsWith('/') || trimmed === '') throw bad();
|
|
91
|
+
return `/openapi/${trimmed}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { parse, normalizeApiPath, METHODS };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { CliError, usage } = require('./errors');
|
|
2
|
+
const { parse, normalizeApiPath, METHODS } = require('./args');
|
|
3
|
+
const { catalogue } = require('./commands');
|
|
4
|
+
const { resolveBody } = require('./input');
|
|
5
|
+
const { USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send } = require('./http');
|
|
6
|
+
const { readCredential, saveCredential, removeCredential } = require('./credentials');
|
|
7
|
+
const { browserLogin, resolveWebOrigin, defaultDevice } = require('./login');
|
|
8
|
+
const { bridge } = require('./mcp');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{env: Record<string, string | undefined>, stdout: {write(s: string): unknown, isTTY?: boolean},
|
|
12
|
+
* stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch,
|
|
13
|
+
* openBrowser?: (url: string) => Promise<void>, loginTimeoutMs?: number}} Io
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs the CLI.
|
|
18
|
+
* @param {string[]} argv
|
|
19
|
+
* @param {Io} io
|
|
20
|
+
* @returns {Promise<number>} exit code
|
|
21
|
+
*/
|
|
22
|
+
async function run(argv, io) {
|
|
23
|
+
const envKey = (io.env.ZOODEALIO_API_KEY || '').trim();
|
|
24
|
+
/** Every key seen this run; none may appear in output. */
|
|
25
|
+
const secrets = new Set(envKey ? [envKey] : []);
|
|
26
|
+
try {
|
|
27
|
+
await execute(argv, io, envKey, secrets);
|
|
28
|
+
return 0;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const error = err instanceof CliError
|
|
31
|
+
? err
|
|
32
|
+
: new CliError({ exit: 1, code: 'internal', title: 'Unexpected error', detail: /** @type {Error} */ (err).message });
|
|
33
|
+
// A key can appear JSON-escaped (newline, quote, backslash) inside the serialized error.
|
|
34
|
+
io.stderr.write(`${scrubKeys(JSON.stringify(error.toJSON()), secrets)}\n`);
|
|
35
|
+
return error.exit;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string[]} argv
|
|
41
|
+
* @param {Io} io
|
|
42
|
+
* @param {string} envKey
|
|
43
|
+
* @param {Set<string>} secrets
|
|
44
|
+
*/
|
|
45
|
+
async function execute(argv, io, envKey, secrets) {
|
|
46
|
+
const parsed = parse(argv);
|
|
47
|
+
if ('help' in parsed) {
|
|
48
|
+
io.stdout.write(parsed.help);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { command, args, flags } = parsed;
|
|
52
|
+
const pretty = !!flags.pretty || !!io.stdout.isTTY;
|
|
53
|
+
/** @param {unknown} value */
|
|
54
|
+
const emit = (value) => io.stdout.write(`${JSON.stringify(value ?? null, null, pretty ? 2 : 0)}\n`);
|
|
55
|
+
|
|
56
|
+
if (command.name === 'commands') return emit(catalogue());
|
|
57
|
+
|
|
58
|
+
const origin = resolveOrigin(io.env, flags.env);
|
|
59
|
+
const stored = readCredential(io.env, origin);
|
|
60
|
+
if (stored) secrets.add(stored.key);
|
|
61
|
+
const source = envKey ? 'env' : stored ? 'login' : undefined;
|
|
62
|
+
const key = envKey || stored?.key || '';
|
|
63
|
+
|
|
64
|
+
if (['login', 'logout', 'mcp'].includes(command.name)) {
|
|
65
|
+
if (flags['dry-run']) throw usage(`--dry-run is not supported for \`${command.name}\``);
|
|
66
|
+
if (command.name === 'login') {
|
|
67
|
+
const result = await browserLogin({
|
|
68
|
+
apiOrigin: origin, webOrigin: resolveWebOrigin(io.env, flags.env), device: (flags.name?.trim() || defaultDevice()).slice(0, 64),
|
|
69
|
+
noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser, timeoutMs: io.loginTimeoutMs, stderr: io.stderr, fetch: io.fetch,
|
|
70
|
+
});
|
|
71
|
+
secrets.add(result.key);
|
|
72
|
+
saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
|
|
73
|
+
return emit({ ok: true, email: result.email, name: result.name, keyPrefix: result.keyPrefix, origin });
|
|
74
|
+
}
|
|
75
|
+
if (command.name === 'logout') {
|
|
76
|
+
if (!stored) {
|
|
77
|
+
return emit({
|
|
78
|
+
ok: true, loggedOut: false,
|
|
79
|
+
detail: envKey ? 'Signed in only through ZOODEALIO_API_KEY; nothing to sign out of.' : 'Not signed in.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await send(io.fetch || fetch, {
|
|
84
|
+
method: 'DELETE', url: buildUrl(origin, `/openapi/Keys/${encodeURIComponent(stored.keyId)}`, []),
|
|
85
|
+
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT, ApiKey: stored.key },
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// 401/404 mean the key is already gone; anything else keeps the login so it can be retried.
|
|
89
|
+
if (!(err instanceof CliError) || (err.status !== 401 && err.status !== 404)) throw err;
|
|
90
|
+
}
|
|
91
|
+
removeCredential(io.env, origin);
|
|
92
|
+
return emit({ ok: true, loggedOut: true, email: stored.email, origin });
|
|
93
|
+
}
|
|
94
|
+
if (!key) throw notSignedIn();
|
|
95
|
+
return bridge({ url: origin, key, stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, fetch: io.fetch, scrub: (s) => scrubKeys(s, secrets), storedLogin: source === 'login' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @type {string} */
|
|
99
|
+
let method;
|
|
100
|
+
/** @type {string} */
|
|
101
|
+
let path;
|
|
102
|
+
/** @type {[string, string][]} */
|
|
103
|
+
const query = [];
|
|
104
|
+
let destructive = !!command.destructive;
|
|
105
|
+
if (command.name === 'api') {
|
|
106
|
+
method = args.method.toUpperCase();
|
|
107
|
+
if (!METHODS.includes(method)) throw usage(`method must be one of ${METHODS.join(', ')}`);
|
|
108
|
+
path = normalizeApiPath(args.path);
|
|
109
|
+
destructive = method === 'DELETE';
|
|
110
|
+
for (const kv of flags.query || []) {
|
|
111
|
+
const eq = kv.indexOf('=');
|
|
112
|
+
if (eq < 1) throw usage(`--query expects k=v, got: ${kv}`);
|
|
113
|
+
query.push([kv.slice(0, eq), kv.slice(eq + 1)]);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
method = /** @type {string} */ (command.method);
|
|
117
|
+
path = /** @type {string} */ (command.path).replace(/\{(\w+)\}/g, (_, name) => encodeURIComponent(args[name]));
|
|
118
|
+
for (const f of command.flags) if (f.query && flags[f.name] !== undefined) query.push([f.query, flags[f.name]]);
|
|
119
|
+
}
|
|
120
|
+
if (destructive && !flags.yes && !flags['dry-run']) throw usage(`\`${command.name}\` is destructive; pass --yes to confirm`);
|
|
121
|
+
|
|
122
|
+
/** @type {unknown} */
|
|
123
|
+
let body;
|
|
124
|
+
if (flags.body !== undefined) {
|
|
125
|
+
if ((command.convenience || []).some((n) => flags[n] !== undefined)) throw usage('use either --body or the convenience flags, not both');
|
|
126
|
+
body = await resolveBody(flags.body, io.stdin);
|
|
127
|
+
} else if (command.build) {
|
|
128
|
+
body = command.build(flags);
|
|
129
|
+
if (body === undefined && command.body === 'required') throw usage('provide --body or the convenience flags');
|
|
130
|
+
} else if (command.body === 'required') {
|
|
131
|
+
throw usage('--body is required');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const needsKey = command.auth !== false;
|
|
135
|
+
if (needsKey && !key && !flags['dry-run']) throw notSignedIn();
|
|
136
|
+
|
|
137
|
+
const url = buildUrl(origin, path, query);
|
|
138
|
+
/** @param {string} apiKey */
|
|
139
|
+
const headers = (apiKey) => ({
|
|
140
|
+
Accept: 'application/json',
|
|
141
|
+
'User-Agent': USER_AGENT,
|
|
142
|
+
...(needsKey ? { ApiKey: apiKey } : {}),
|
|
143
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
144
|
+
...(flags['if-match'] === undefined ? {} : { 'If-Match': flags['if-match'] }),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (flags['dry-run']) return emit({ method, url, headers: headers(redactKey(key)), body: body ?? null });
|
|
148
|
+
|
|
149
|
+
/** @type {unknown} */
|
|
150
|
+
let result;
|
|
151
|
+
try {
|
|
152
|
+
result = await send(io.fetch || fetch, { method, url, headers: headers(key), body });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (source === 'login' && err instanceof CliError && err.status === 401) {
|
|
155
|
+
throw new CliError({ exit: 3, status: 401, code: 'unauthorized', title: 'Login revoked or expired', detail: 'The stored login was revoked or expired. Run zoodealio login again.' });
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
if (command.name === 'keys revoke' && source === 'login' && stored && args.id === stored.keyId) removeCredential(io.env, origin);
|
|
160
|
+
if (command.name === 'whoami') return emit({ ok: true, origin, key: redactKey(key), source, ...(source === 'login' && stored ? { email: stored.email } : {}) });
|
|
161
|
+
return emit(result);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function notSignedIn() {
|
|
165
|
+
return new CliError({ exit: 3, code: 'unauthorized', title: 'Not signed in', detail: 'Not signed in. Run zoodealio login, or set ZOODEALIO_API_KEY.' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { run };
|