zoodealio 0.1.0 → 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 +78 -23
- package/package.json +1 -1
- package/src/cli.js +73 -18
- package/src/commands.js +16 -1
- package/src/credentials.js +87 -0
- package/src/http.js +17 -1
- package/src/login.js +136 -0
- package/src/mcp.js +107 -0
package/README.md
CHANGED
|
@@ -10,22 +10,38 @@ It has no runtime dependencies and needs Node 22 or newer.
|
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
-
Run it without installing:
|
|
14
|
-
|
|
15
13
|
```sh
|
|
16
|
-
|
|
14
|
+
npm install -g zoodealio
|
|
15
|
+
zoodealio login
|
|
16
|
+
zoodealio whoami
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
or
|
|
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)
|
|
20
26
|
|
|
21
27
|
```sh
|
|
22
|
-
|
|
23
|
-
zoodealio
|
|
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
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
|
|
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.
|
|
27
37
|
|
|
28
|
-
|
|
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
|
|
29
45
|
|
|
30
46
|
Copy a personal API key from **Settings → API** in Zoodealio AI and export it:
|
|
31
47
|
|
|
@@ -33,12 +49,46 @@ Copy a personal API key from **Settings → API** in Zoodealio AI and export it:
|
|
|
33
49
|
export ZOODEALIO_API_KEY=zoo_...
|
|
34
50
|
```
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
```
|
|
40
89
|
|
|
41
|
-
`zoodealio
|
|
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.
|
|
42
92
|
|
|
43
93
|
## Environments
|
|
44
94
|
|
|
@@ -69,9 +119,9 @@ for usage and network errors. A 429 adds `retryAfter` in seconds.
|
|
|
69
119
|
| Exit | Meaning | `code` |
|
|
70
120
|
| --- | --- | --- |
|
|
71
121
|
| 0 | Success | |
|
|
72
|
-
| 1 | Other API or network error | `http_error`, `network_error` |
|
|
73
|
-
| 2 | Usage: unknown command or flag, missing argument, bad ID or JSON, missing `--yes`,
|
|
74
|
-
| 3 | Not
|
|
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` |
|
|
75
125
|
| 4 | Not found (404) | `not_found` |
|
|
76
126
|
| 5 | Conflict, precondition or validation failure (409, 412, 422) | `conflict`, `precondition_failed`, `validation_failed` |
|
|
77
127
|
| 6 | Rate limited (429) | `rate_limited` |
|
|
@@ -120,12 +170,15 @@ concurrency on `properties update`, `offers accept` and `offers request-details`
|
|
|
120
170
|
| `webhooks create` | POST Webhooks | `--url` and `--event` (repeatable) or `--body` |
|
|
121
171
|
| `webhooks update <id>` | PUT Webhooks/{id} | `--body` (`endpointUrl`, `eventTypes`, `isActive`) |
|
|
122
172
|
| `webhooks rotate <id>`, `webhooks delete <id>` | POST, DELETE | `--yes` required |
|
|
123
|
-
| `
|
|
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?}` |
|
|
124
177
|
| `openapi` | GET `/_openapi/partner.json` | Partner OpenAPI document; no key needed |
|
|
125
178
|
| `api <METHOD> <path>` | any `/openapi/...` | `--query k=v` (repeatable), `--body`; `DELETE` needs `--yes` |
|
|
126
179
|
| `commands` | | Command catalogue as JSON |
|
|
127
180
|
|
|
128
|
-
Destructive commands (`offers accept`, `team remove`, `properties photos delete`,
|
|
181
|
+
Destructive commands (`keys revoke`, `offers accept`, `team remove`, `properties photos delete`,
|
|
129
182
|
`properties documents delete`, `webhooks rotate`, `webhooks delete`, and `api ... DELETE`)
|
|
130
183
|
refuse to run without `--yes`, exit 2, and send nothing. `--dry-run` previews them
|
|
131
184
|
without `--yes`, since it sends nothing either.
|
|
@@ -179,8 +232,8 @@ zoodealio api GET Properties --query pageSize=5
|
|
|
179
232
|
|
|
180
233
|
- No multipart uploads of local files. Photos and documents are imported from URLs only
|
|
181
234
|
(`photos add --url`); `api` cannot send multipart either.
|
|
182
|
-
- No
|
|
183
|
-
|
|
235
|
+
- No keychain or interactive prompts. Credentials are `ZOODEALIO_API_KEY` or the
|
|
236
|
+
`credentials.json` written by `zoodealio login`.
|
|
184
237
|
|
|
185
238
|
## Development
|
|
186
239
|
|
|
@@ -192,13 +245,15 @@ npm run check # eslint, tsc --noEmit (checkJs), node --test
|
|
|
192
245
|
|
|
193
246
|
## Releasing
|
|
194
247
|
|
|
195
|
-
|
|
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:
|
|
196
250
|
|
|
197
251
|
```sh
|
|
198
252
|
cd integrations/cli
|
|
199
253
|
npm version <patch|minor|major> --no-git-tag-version # in a PR, then merge
|
|
200
254
|
npm run check
|
|
201
|
-
npm publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
|
|
255
|
+
npm stage publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
|
|
202
256
|
```
|
|
203
257
|
|
|
204
|
-
|
|
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/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,12 +2,15 @@ const { CliError, usage } = require('./errors');
|
|
|
2
2
|
const { parse, normalizeApiPath, METHODS } = require('./args');
|
|
3
3
|
const { catalogue } = require('./commands');
|
|
4
4
|
const { resolveBody } = require('./input');
|
|
5
|
-
const { redactKey, resolveOrigin, buildUrl, send } = require('./http');
|
|
6
|
-
const
|
|
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');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* @typedef {{env: Record<string, string | undefined>, stdout: {write(s: string): unknown, isTTY?: boolean},
|
|
10
|
-
* stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch
|
|
12
|
+
* stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch,
|
|
13
|
+
* openBrowser?: (url: string) => Promise<void>, loginTimeoutMs?: number}} Io
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -17,20 +20,18 @@ const pkg = require('../package.json');
|
|
|
17
20
|
* @returns {Promise<number>} exit code
|
|
18
21
|
*/
|
|
19
22
|
async function run(argv, io) {
|
|
20
|
-
const
|
|
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] : []);
|
|
21
26
|
try {
|
|
22
|
-
await execute(argv, io,
|
|
27
|
+
await execute(argv, io, envKey, secrets);
|
|
23
28
|
return 0;
|
|
24
29
|
} catch (err) {
|
|
25
30
|
const error = err instanceof CliError
|
|
26
31
|
? err
|
|
27
32
|
: new CliError({ exit: 1, code: 'internal', title: 'Unexpected error', detail: /** @type {Error} */ (err).message });
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// The key can appear JSON-escaped (newline, quote, backslash) inside the serialized error.
|
|
31
|
-
for (const form of [key, JSON.stringify(key).slice(1, -1)]) text = text.split(form).join(redactKey(key));
|
|
32
|
-
}
|
|
33
|
-
io.stderr.write(`${text}\n`);
|
|
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`);
|
|
34
35
|
return error.exit;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -38,9 +39,10 @@ async function run(argv, io) {
|
|
|
38
39
|
/**
|
|
39
40
|
* @param {string[]} argv
|
|
40
41
|
* @param {Io} io
|
|
41
|
-
* @param {string}
|
|
42
|
+
* @param {string} envKey
|
|
43
|
+
* @param {Set<string>} secrets
|
|
42
44
|
*/
|
|
43
|
-
async function execute(argv, io,
|
|
45
|
+
async function execute(argv, io, envKey, secrets) {
|
|
44
46
|
const parsed = parse(argv);
|
|
45
47
|
if ('help' in parsed) {
|
|
46
48
|
io.stdout.write(parsed.help);
|
|
@@ -53,6 +55,46 @@ async function execute(argv, io, key) {
|
|
|
53
55
|
|
|
54
56
|
if (command.name === 'commands') return emit(catalogue());
|
|
55
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
|
+
|
|
56
98
|
/** @type {string} */
|
|
57
99
|
let method;
|
|
58
100
|
/** @type {string} */
|
|
@@ -90,14 +132,13 @@ async function execute(argv, io, key) {
|
|
|
90
132
|
}
|
|
91
133
|
|
|
92
134
|
const needsKey = command.auth !== false;
|
|
93
|
-
if (needsKey && !key && !flags['dry-run']) throw
|
|
135
|
+
if (needsKey && !key && !flags['dry-run']) throw notSignedIn();
|
|
94
136
|
|
|
95
|
-
const origin = resolveOrigin(io.env, flags.env);
|
|
96
137
|
const url = buildUrl(origin, path, query);
|
|
97
138
|
/** @param {string} apiKey */
|
|
98
139
|
const headers = (apiKey) => ({
|
|
99
140
|
Accept: 'application/json',
|
|
100
|
-
'User-Agent':
|
|
141
|
+
'User-Agent': USER_AGENT,
|
|
101
142
|
...(needsKey ? { ApiKey: apiKey } : {}),
|
|
102
143
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
103
144
|
...(flags['if-match'] === undefined ? {} : { 'If-Match': flags['if-match'] }),
|
|
@@ -105,9 +146,23 @@ async function execute(argv, io, key) {
|
|
|
105
146
|
|
|
106
147
|
if (flags['dry-run']) return emit({ method, url, headers: headers(redactKey(key)), body: body ?? null });
|
|
107
148
|
|
|
108
|
-
|
|
109
|
-
|
|
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 } : {}) });
|
|
110
161
|
return emit(result);
|
|
111
162
|
}
|
|
112
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
|
+
|
|
113
168
|
module.exports = { run };
|
package/src/commands.js
CHANGED
|
@@ -135,6 +135,21 @@ const COMMANDS = [
|
|
|
135
135
|
cmd('webhooks deliveries', 'List webhook deliveries', 'GET', '/openapi/Webhooks/deliveries', {
|
|
136
136
|
flags: [str('subscription', 'Subscription id', { query: 'subscriptionId' }), str('status', 'Delivery status', { query: 'status' }), ...PAGING],
|
|
137
137
|
}),
|
|
138
|
+
cmd('login', 'Sign in through the browser and store a CLI key', null, null, {
|
|
139
|
+
auth: false,
|
|
140
|
+
flags: [
|
|
141
|
+
{ name: 'no-browser', type: 'boolean', description: 'Print the sign-in URL to stderr instead of opening a browser' },
|
|
142
|
+
str('name', 'Device name shown at sign-in (default: this host)'),
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
cmd('logout', 'Revoke the stored CLI key and forget it', 'DELETE', '/openapi/Keys/{keyId}'),
|
|
146
|
+
cmd('keys list', 'List your API keys (needs a zoodealio login)', 'GET', '/openapi/Keys'),
|
|
147
|
+
cmd('keys create', 'Create an API key; the raw key is shown once', 'POST', '/openapi/Keys', {
|
|
148
|
+
flags: [str('name', 'Key name', { required: true })],
|
|
149
|
+
build: (f) => ({ name: f.name }),
|
|
150
|
+
}),
|
|
151
|
+
cmd('keys revoke', 'Revoke an API key', 'DELETE', '/openapi/Keys/{id}', { args: [id('id', 'Key id')], destructive: true }),
|
|
152
|
+
cmd('mcp', 'Run a stdio MCP server bridged to /openapi/mcp', 'POST', '/openapi/mcp'),
|
|
138
153
|
cmd('whoami', 'Check the API key and show the target origin', 'GET', '/openapi/Webhooks/event-types'),
|
|
139
154
|
cmd('openapi', 'Print the partner OpenAPI document (no key needed)', 'GET', '/_openapi/partner.json', { auth: false }),
|
|
140
155
|
cmd('api', 'Call any /openapi endpoint directly (DELETE needs --yes)', null, null, {
|
|
@@ -194,7 +209,7 @@ function helpText(prefix) {
|
|
|
194
209
|
for (const a of exact.args) lines.push(` <${a.name}> ${a.description}`);
|
|
195
210
|
for (const f of exact.flags) lines.push(` --${f.name} ${f.description}${f.required ? ' (required)' : ''}`);
|
|
196
211
|
} else {
|
|
197
|
-
lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr.
|
|
212
|
+
lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr. Sign in with `zoodealio login` or set ZOODEALIO_API_KEY.', '');
|
|
198
213
|
for (const c of matches) lines.push(` ${c.name.padEnd(28)}${c.summary}`);
|
|
199
214
|
lines.push('', 'Global flags:');
|
|
200
215
|
for (const f of GLOBAL_FLAGS) lines.push(` --${f.name.padEnd(10)}${f.description}`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { CliError } = require('./errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{key: string, keyId: string, email: string, name: string}} Credential
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Record<string, string | undefined>} env
|
|
12
|
+
* @returns {string} directory holding credentials.json
|
|
13
|
+
*/
|
|
14
|
+
function configDir(env) {
|
|
15
|
+
if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, 'zoodealio');
|
|
16
|
+
if (process.platform === 'win32' && env.APPDATA) return path.join(env.APPDATA, 'zoodealio');
|
|
17
|
+
return path.join(env.HOME || os.homedir(), '.config', 'zoodealio');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Record<string, string | undefined>} env
|
|
22
|
+
* @param {boolean} [strict] throw instead of returning empty when the file exists but is unreadable, so a rewrite cannot wipe it
|
|
23
|
+
* @returns {Record<string, Credential>} stored logins keyed by API origin; empty when missing (or unreadable, unless strict)
|
|
24
|
+
*/
|
|
25
|
+
function readAll(env, strict = false) {
|
|
26
|
+
const file = path.join(configDir(env), 'credentials.json');
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return {};
|
|
32
|
+
}
|
|
33
|
+
if (strict) {
|
|
34
|
+
throw new CliError({ exit: 1, code: 'credentials_unreadable', title: 'Credentials file is unreadable', detail: `${file} is not valid JSON. Fix or delete it, then run zoodealio login again.` });
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Record<string, string | undefined>} env
|
|
41
|
+
* @param {string} origin
|
|
42
|
+
* @returns {Credential | undefined}
|
|
43
|
+
*/
|
|
44
|
+
function readCredential(env, origin) {
|
|
45
|
+
const all = readAll(env);
|
|
46
|
+
const entry = Object.hasOwn(all, origin) ? all[origin] : undefined;
|
|
47
|
+
return entry && typeof entry.key === 'string' && entry.key ? entry : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Writes atomically (temp file, then rename) with owner-only permissions.
|
|
52
|
+
* @param {Record<string, string | undefined>} env
|
|
53
|
+
* @param {Record<string, Credential>} all
|
|
54
|
+
*/
|
|
55
|
+
function writeAll(env, all) {
|
|
56
|
+
const dir = configDir(env);
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
58
|
+
const temp = path.join(dir, `credentials.json.${process.pid}.tmp`);
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(temp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600, flag: 'w' });
|
|
61
|
+
fs.renameSync(temp, path.join(dir, 'credentials.json'));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
fs.rmSync(temp, { force: true });
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {Record<string, string | undefined>} env
|
|
70
|
+
* @param {string} origin
|
|
71
|
+
* @param {Credential} credential
|
|
72
|
+
*/
|
|
73
|
+
function saveCredential(env, origin, credential) {
|
|
74
|
+
writeAll(env, { ...readAll(env, true), [origin]: credential });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {Record<string, string | undefined>} env
|
|
79
|
+
* @param {string} origin
|
|
80
|
+
*/
|
|
81
|
+
function removeCredential(env, origin) {
|
|
82
|
+
const all = readAll(env, true);
|
|
83
|
+
delete all[origin];
|
|
84
|
+
writeAll(env, all);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { configDir, readCredential, saveCredential, removeCredential };
|
package/src/http.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { CliError, usage } = require('./errors');
|
|
2
|
+
const pkg = require('../package.json');
|
|
2
3
|
|
|
4
|
+
const USER_AGENT = `zoodealio-cli/${pkg.version}`;
|
|
3
5
|
const ENVS = { prod: 'https://api.zoodealio.ai', dev: 'https://dev.api.zoodealio.ai' };
|
|
4
6
|
|
|
5
7
|
/** @type {Record<number, [number, string]>} status -> [exit code, error code slug] */
|
|
@@ -23,6 +25,20 @@ function redactKey(key) {
|
|
|
23
25
|
return key.length >= 12 ? `zoo_…${key.slice(-4)}` : 'zoo_…';
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Replaces every known key in text, raw or JSON-escaped, with its redacted form.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @param {Iterable<string>} keys
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function scrubKeys(text, keys) {
|
|
35
|
+
let out = text;
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
for (const form of [key, JSON.stringify(key).slice(1, -1)]) out = out.split(form).join(redactKey(key));
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* @param {Record<string, string | undefined>} env
|
|
28
44
|
* @param {string | undefined} envName
|
|
@@ -107,4 +123,4 @@ async function send(doFetch, req) {
|
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
|
|
110
|
-
module.exports = { ENVS, redactKey, resolveOrigin, buildUrl, send };
|
|
126
|
+
module.exports = { ENVS, USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send };
|
package/src/login.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const { CliError } = require('./errors');
|
|
6
|
+
const { USER_AGENT, send } = require('./http');
|
|
7
|
+
|
|
8
|
+
const WEB_ENVS = { prod: 'https://zoodealio.ai', dev: 'https://dev.zoodealio.ai' };
|
|
9
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {Record<string, string | undefined>} env
|
|
13
|
+
* @param {string | undefined} envName
|
|
14
|
+
* @returns {string} web origin without trailing slash
|
|
15
|
+
*/
|
|
16
|
+
function resolveWebOrigin(env, envName) {
|
|
17
|
+
if (env.ZOODEALIO_WEB_URL) return env.ZOODEALIO_WEB_URL.replace(/\/+$/, '');
|
|
18
|
+
return WEB_ENVS[/** @type {'prod'|'dev'} */ (envName === 'dev' ? 'dev' : 'prod')];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opens a URL in the default browser. Rejects when no opener could be started.
|
|
23
|
+
* @param {string} url
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
function openBrowser(url) {
|
|
27
|
+
const [command, args, options] = process.platform === 'win32'
|
|
28
|
+
? ['cmd', ['/d', '/s', '/c', `start "" "${url}"`], { windowsVerbatimArguments: true }]
|
|
29
|
+
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [url], {}];
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, ...options });
|
|
32
|
+
child.once('error', reject);
|
|
33
|
+
child.once('spawn', () => {
|
|
34
|
+
child.unref();
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PAGE = (/** @type {string} */ message) => `<!doctype html><meta charset="utf-8"><title>Zoodealio CLI</title><body style="font-family:system-ui;text-align:center;margin-top:4rem"><h1>${message}</h1><p>You can close this tab and return to your terminal.</p>`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Listens on a loopback port for the browser's one redirect back to /callback.
|
|
44
|
+
* @param {string} state
|
|
45
|
+
* @param {number} timeoutMs
|
|
46
|
+
* @returns {Promise<{port: number, result: Promise<{code?: string, error?: string}>, close: () => void}>}
|
|
47
|
+
*/
|
|
48
|
+
async function listenForCallback(state, timeoutMs) {
|
|
49
|
+
/** @type {(r: {code?: string, error?: string}) => void} */
|
|
50
|
+
let finish = () => {};
|
|
51
|
+
/** @type {(e: Error) => void} */
|
|
52
|
+
let fail = () => {};
|
|
53
|
+
const result = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
|
|
54
|
+
const stateBuffer = Buffer.from(state);
|
|
55
|
+
const server = http.createServer((req, res) => {
|
|
56
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
57
|
+
const got = Buffer.from(url.searchParams.get('state') || '');
|
|
58
|
+
res.setHeader('Connection', 'close');
|
|
59
|
+
if (req.method !== 'GET' || url.pathname !== '/callback') {
|
|
60
|
+
res.writeHead(404).end();
|
|
61
|
+
} else if (got.length !== stateBuffer.length || !crypto.timingSafeEqual(got, stateBuffer)) {
|
|
62
|
+
// A stray or forged request must not be able to abort the real login.
|
|
63
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in link did not match'));
|
|
64
|
+
} else if (url.searchParams.get('error')) {
|
|
65
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in cancelled'));
|
|
66
|
+
finish({ error: /** @type {string} */ (url.searchParams.get('error')) });
|
|
67
|
+
} else if (url.searchParams.get('code')) {
|
|
68
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Signed in to the Zoodealio CLI'));
|
|
69
|
+
finish({ code: /** @type {string} */ (url.searchParams.get('code')) });
|
|
70
|
+
} else {
|
|
71
|
+
res.writeHead(400).end();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
75
|
+
server.once('error', reject);
|
|
76
|
+
server.listen(0, '127.0.0.1', () => resolve(undefined));
|
|
77
|
+
});
|
|
78
|
+
const timer = setTimeout(() => fail(new CliError({ exit: 1, code: 'login_timeout', title: 'Login timed out', detail: 'No sign-in came back from the browser. Run zoodealio login again.' })), timeoutMs);
|
|
79
|
+
return {
|
|
80
|
+
port: /** @type {import('node:net').AddressInfo} */ (server.address()).port,
|
|
81
|
+
result,
|
|
82
|
+
close: () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
server.close();
|
|
85
|
+
server.closeIdleConnections();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Browser sign-in (authorization code + PKCE). Returns the exchange response; the caller stores it.
|
|
92
|
+
* @param {{apiOrigin: string, webOrigin: string, device: string, noBrowser: boolean,
|
|
93
|
+
* openBrowser?: (url: string) => Promise<void>, timeoutMs?: number, stderr: {write(s: string): unknown}, fetch?: typeof fetch}} opts
|
|
94
|
+
* @returns {Promise<{key: string, keyId: string, keyPrefix: string, name: string, email: string}>}
|
|
95
|
+
*/
|
|
96
|
+
async function browserLogin(opts) {
|
|
97
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
98
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
99
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
100
|
+
const listener = await listenForCallback(state, opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
|
|
101
|
+
try {
|
|
102
|
+
const url = `${opts.webOrigin}/cli/login?${new URLSearchParams({ port: String(listener.port), state, challenge, device: opts.device })}`;
|
|
103
|
+
let opened = false;
|
|
104
|
+
if (!opts.noBrowser) {
|
|
105
|
+
try {
|
|
106
|
+
await (opts.openBrowser || openBrowser)(url);
|
|
107
|
+
opened = true;
|
|
108
|
+
} catch {
|
|
109
|
+
// fall through to printing the URL
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!opened) opts.stderr.write(`Open this URL in a browser to sign in:\n${url}\n`);
|
|
113
|
+
|
|
114
|
+
const { code, error } = await listener.result;
|
|
115
|
+
if (!code) {
|
|
116
|
+
throw new CliError({
|
|
117
|
+
exit: 1, code: error === 'cancelled' ? 'login_cancelled' : 'login_failed',
|
|
118
|
+
title: error === 'cancelled' ? 'Login cancelled' : 'Login failed',
|
|
119
|
+
detail: error === 'cancelled' ? 'Sign-in was cancelled in the browser.' : `The browser reported: ${error}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return /** @type {any} */ (await send(opts.fetch || fetch, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: `${opts.apiOrigin}/openapi/cli-logins/exchange`,
|
|
125
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
126
|
+
body: { code, verifier },
|
|
127
|
+
}));
|
|
128
|
+
} finally {
|
|
129
|
+
listener.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @returns {string} */
|
|
134
|
+
const defaultDevice = () => os.hostname().slice(0, 64) || 'cli';
|
|
135
|
+
|
|
136
|
+
module.exports = { browserLogin, resolveWebOrigin, defaultDevice };
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { StringDecoder } = require('node:string_decoder');
|
|
2
|
+
const { USER_AGENT } = require('./http');
|
|
3
|
+
|
|
4
|
+
/** @typedef {{write(s: string): unknown}} Sink */
|
|
5
|
+
|
|
6
|
+
/** @param {string} text @returns {unknown[]} the JSON payload of each data event */
|
|
7
|
+
function sseMessages(text) {
|
|
8
|
+
const messages = [];
|
|
9
|
+
for (const event of text.split(/\r?\n\r?\n/)) {
|
|
10
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
|
|
11
|
+
if (!data) continue;
|
|
12
|
+
try {
|
|
13
|
+
messages.push(JSON.parse(data));
|
|
14
|
+
} catch {
|
|
15
|
+
// not JSON (keep-alive or comment)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return messages;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bridges newline-delimited JSON-RPC on stdin to the remote MCP endpoint, one POST per message.
|
|
23
|
+
* Stdout carries only JSON-RPC responses; logs go to stderr.
|
|
24
|
+
* @param {{url: string, key: string, stdin: AsyncIterable<string | Buffer>, stdout: Sink, stderr: Sink,
|
|
25
|
+
* fetch?: typeof fetch, scrub?: (s: string) => string, storedLogin?: boolean}} opts
|
|
26
|
+
*/
|
|
27
|
+
async function bridge(opts) {
|
|
28
|
+
const doFetch = opts.fetch || fetch;
|
|
29
|
+
const scrub = opts.scrub || ((/** @type {string} */ s) => s);
|
|
30
|
+
/** @type {string | undefined} */
|
|
31
|
+
let sessionId;
|
|
32
|
+
/** @type {Set<Promise<void>>} */
|
|
33
|
+
const pending = new Set();
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} message */
|
|
36
|
+
const emit = (message) => opts.stdout.write(`${JSON.stringify(message)}\n`);
|
|
37
|
+
/** @param {unknown} id @param {number} code @param {string} message */
|
|
38
|
+
const fail = (id, code, message) => {
|
|
39
|
+
const text = scrub(message);
|
|
40
|
+
opts.stderr.write(`zoodealio mcp: ${text}\n`);
|
|
41
|
+
if (id !== undefined) emit({ jsonrpc: '2.0', id, error: { code, message: text } });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** @param {string} line */
|
|
45
|
+
async function forward(line) {
|
|
46
|
+
let message;
|
|
47
|
+
try {
|
|
48
|
+
message = JSON.parse(line);
|
|
49
|
+
} catch {
|
|
50
|
+
return fail(null, -32700, 'Parse error: input line is not valid JSON');
|
|
51
|
+
}
|
|
52
|
+
const id = message && typeof message === 'object' && 'id' in message ? message.id : undefined;
|
|
53
|
+
try {
|
|
54
|
+
const res = await doFetch(`${opts.url}/openapi/mcp`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
ApiKey: opts.key,
|
|
58
|
+
Accept: 'application/json, text/event-stream',
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
'User-Agent': USER_AGENT,
|
|
61
|
+
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
|
|
62
|
+
},
|
|
63
|
+
body: line,
|
|
64
|
+
redirect: 'error',
|
|
65
|
+
signal: AbortSignal.timeout(300000),
|
|
66
|
+
});
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
sessionId ??= res.headers.get('mcp-session-id') || undefined;
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
let detail = '';
|
|
71
|
+
try {
|
|
72
|
+
const problem = JSON.parse(text);
|
|
73
|
+
detail = problem.detail || problem.title || '';
|
|
74
|
+
} catch {
|
|
75
|
+
// non-JSON error body
|
|
76
|
+
}
|
|
77
|
+
const hint = res.status === 401 && opts.storedLogin ? '. The stored login was revoked or expired; run zoodealio login again' : '';
|
|
78
|
+
return fail(id, -32000, `HTTP ${res.status}${detail ? `: ${detail}` : ''}${hint}`);
|
|
79
|
+
}
|
|
80
|
+
if (!text.trim()) return;
|
|
81
|
+
const payloads = (res.headers.get('content-type') || '').includes('text/event-stream') ? sseMessages(text) : [JSON.parse(text)];
|
|
82
|
+
for (const payload of payloads.flat()) emit(payload);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const e = /** @type {{message?: string, cause?: {message?: string}}} */ (err);
|
|
85
|
+
fail(id, -32000, e.cause?.message || e.message || 'request failed');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const decoder = new StringDecoder('utf8');
|
|
90
|
+
let buffer = '';
|
|
91
|
+
/** @param {string} line */
|
|
92
|
+
const dispatch = (line) => {
|
|
93
|
+
if (!line.trim()) return;
|
|
94
|
+
const p = forward(line).finally(() => pending.delete(p));
|
|
95
|
+
pending.add(p);
|
|
96
|
+
};
|
|
97
|
+
for await (const chunk of opts.stdin) {
|
|
98
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
99
|
+
const lines = buffer.split('\n');
|
|
100
|
+
buffer = /** @type {string} */ (lines.pop());
|
|
101
|
+
lines.forEach(dispatch);
|
|
102
|
+
}
|
|
103
|
+
dispatch(buffer + decoder.end());
|
|
104
|
+
await Promise.all(pending);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { bridge };
|