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 CHANGED
@@ -1,3 +1,259 @@
1
- # Temporary Holding Version
1
+ # Zoodealio CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ const { run } = require('../src/cli');
3
+
4
+ run(process.argv.slice(2), {
5
+ env: process.env,
6
+ stdout: process.stdout,
7
+ stderr: process.stderr,
8
+ stdin: process.stdin,
9
+ }).then((code) => {
10
+ process.exitCode = code;
11
+ });
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "zoodealio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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 };