zoodealio 0.1.0 → 0.2.1
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 +88 -27
- package/bin/zoodealio.js +23 -0
- package/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +97 -18
- package/src/commands.js +46 -2
- package/src/credentials.js +89 -0
- package/src/http.js +17 -1
- package/src/login.js +212 -0
- package/src/mcp.js +107 -0
- package/src/ui.js +59 -0
package/README.md
CHANGED
|
@@ -2,30 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
`zoodealio` is a command line wrapper over the partner API (`/openapi`). It prints JSON,
|
|
4
4
|
takes JSON bodies, and describes itself, so people, scripts and AI agents can drive the
|
|
5
|
-
same contract as the Zoodealio MCP server
|
|
6
|
-
|
|
5
|
+
same contract as the Zoodealio MCP server and the Zoodealio Zapier app. It adds no API
|
|
6
|
+
surface: every command is one
|
|
7
7
|
documented `/openapi` request, and bodies pass through unchanged.
|
|
8
8
|
|
|
9
9
|
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` opens your browser on the sign-in page, explains the steps and prints the link. In a
|
|
35
|
+
terminal, press **Enter** to open it again, **c** to copy the link, or **q** to cancel; the link also
|
|
36
|
+
works pasted into any browser, on this or another device. Sign in the way you usually do and click **Authorize**; the terminal
|
|
37
|
+
finishes by itself and suggests what to run next. Run by a script or an agent (no terminal), it
|
|
38
|
+
opens the browser the same way and prints the link, without the key prompt. All of this goes to stderr.
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
Under the hood it binds a loopback port, sends you to Zoodealio AI to approve, and exchanges the
|
|
41
|
+
one-time code (with PKCE) for a personal API key named `CLI – <device>`. It waits up to 5 minutes.
|
|
42
|
+
It prints `{ok, email, name, keyPrefix, origin}` on stdout and never the key.
|
|
43
|
+
|
|
44
|
+
The key is stored per API origin in `$XDG_CONFIG_HOME/zoodealio/credentials.json`
|
|
45
|
+
(default `~/.config/zoodealio/credentials.json`; `%APPDATA%\zoodealio\credentials.json` on Windows),
|
|
46
|
+
written atomically with mode `0600` in a `0700` directory. Keys are per environment:
|
|
47
|
+
`zoodealio login --env dev` stores a separate login. `ZOODEALIO_WEB_URL` overrides the sign-in
|
|
48
|
+
page origin the way `ZOODEALIO_API_URL` overrides the API origin.
|
|
49
|
+
|
|
50
|
+
### API key for CI and servers
|
|
29
51
|
|
|
30
52
|
Copy a personal API key from **Settings → API** in Zoodealio AI and export it:
|
|
31
53
|
|
|
@@ -33,12 +55,46 @@ Copy a personal API key from **Settings → API** in Zoodealio AI and export it:
|
|
|
33
55
|
export ZOODEALIO_API_KEY=zoo_...
|
|
34
56
|
```
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
`ZOODEALIO_API_KEY` wins over a stored login. If neither is present the CLI exits 3 with
|
|
59
|
+
`Not signed in. Run zoodealio login, or set ZOODEALIO_API_KEY.` and sends nothing.
|
|
60
|
+
A 401 on a stored login means it was revoked or expired; run `zoodealio login` again.
|
|
61
|
+
|
|
62
|
+
The key is sent only as the `ApiKey` header. It is never printed: errors, `--dry-run` output and
|
|
63
|
+
`whoami` show it as `zoo_…<last4>` (the one exception is `keys create`, whose purpose is to show a
|
|
64
|
+
new key once). Redirects are refused so the header cannot be forwarded. The key keeps its owner's
|
|
65
|
+
workspace and property scope. Every request sends `User-Agent: zoodealio-cli/<version>`.
|
|
66
|
+
|
|
67
|
+
`zoodealio whoami` confirms the key works and shows the target origin, `source` (`login` or
|
|
68
|
+
`env`) and, for a login, the `email`.
|
|
69
|
+
|
|
70
|
+
### Keys
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
zoodealio keys list # your keys; "current" marks the one in use
|
|
74
|
+
zoodealio keys create --name ci # prints the raw key once
|
|
75
|
+
zoodealio keys revoke <id> --yes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Key management needs a key created by `zoodealio login`; keys made with `keys create` or in the web
|
|
79
|
+
app cannot manage keys.
|
|
80
|
+
|
|
81
|
+
### MCP
|
|
82
|
+
|
|
83
|
+
`zoodealio mcp` is a stdio MCP server that forwards each JSON-RPC message to the hosted
|
|
84
|
+
`/openapi/mcp` endpoint with your key, so MCP clients need no HTTP or header setup.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
claude mcp add zoodealio -- zoodealio mcp
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Cursor (`.cursor/mcp.json`) or VS Code (`.vscode/mcp.json`, under `servers`):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{ "mcpServers": { "zoodealio": { "command": "zoodealio", "args": ["mcp"] } } }
|
|
94
|
+
```
|
|
40
95
|
|
|
41
|
-
`zoodealio
|
|
96
|
+
Run `zoodealio login` first (or put `ZOODEALIO_API_KEY` in the client's `env`). Stdout carries only
|
|
97
|
+
JSON-RPC; logs go to stderr.
|
|
42
98
|
|
|
43
99
|
## Environments
|
|
44
100
|
|
|
@@ -69,9 +125,9 @@ for usage and network errors. A 429 adds `retryAfter` in seconds.
|
|
|
69
125
|
| Exit | Meaning | `code` |
|
|
70
126
|
| --- | --- | --- |
|
|
71
127
|
| 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
|
|
128
|
+
| 1 | Other API or network error; login cancelled or timed out | `http_error`, `network_error`, `login_cancelled`, `login_timeout` |
|
|
129
|
+
| 2 | Usage: unknown command or flag, missing argument, bad ID or JSON, missing `--yes`, or HTTP 400 | `usage`, `bad_request` |
|
|
130
|
+
| 3 | Not signed in, or not authenticated or allowed (401, 403) | `unauthorized`, `forbidden` |
|
|
75
131
|
| 4 | Not found (404) | `not_found` |
|
|
76
132
|
| 5 | Conflict, precondition or validation failure (409, 412, 422) | `conflict`, `precondition_failed`, `validation_failed` |
|
|
77
133
|
| 6 | Rate limited (429) | `rate_limited` |
|
|
@@ -120,12 +176,15 @@ concurrency on `properties update`, `offers accept` and `offers request-details`
|
|
|
120
176
|
| `webhooks create` | POST Webhooks | `--url` and `--event` (repeatable) or `--body` |
|
|
121
177
|
| `webhooks update <id>` | PUT Webhooks/{id} | `--body` (`endpointUrl`, `eventTypes`, `isActive`) |
|
|
122
178
|
| `webhooks rotate <id>`, `webhooks delete <id>` | POST, DELETE | `--yes` required |
|
|
123
|
-
| `
|
|
179
|
+
| `login`, `logout` | | Browser sign-in and sign-out; `login` takes `--no-browser`, `--name` |
|
|
180
|
+
| `keys list\|create\|revoke` | GET, POST, DELETE Keys | `create`: `--name`; `revoke <id>` needs `--yes`; needs a `login` key |
|
|
181
|
+
| `mcp` | POST `/openapi/mcp` | stdio MCP bridge |
|
|
182
|
+
| `whoami` | | Key check; prints `{ok, origin, key, source, email?}` |
|
|
124
183
|
| `openapi` | GET `/_openapi/partner.json` | Partner OpenAPI document; no key needed |
|
|
125
184
|
| `api <METHOD> <path>` | any `/openapi/...` | `--query k=v` (repeatable), `--body`; `DELETE` needs `--yes` |
|
|
126
185
|
| `commands` | | Command catalogue as JSON |
|
|
127
186
|
|
|
128
|
-
Destructive commands (`offers accept`, `team remove`, `properties photos delete`,
|
|
187
|
+
Destructive commands (`keys revoke`, `offers accept`, `team remove`, `properties photos delete`,
|
|
129
188
|
`properties documents delete`, `webhooks rotate`, `webhooks delete`, and `api ... DELETE`)
|
|
130
189
|
refuse to run without `--yes`, exit 2, and send nothing. `--dry-run` previews them
|
|
131
190
|
without `--yes`, since it sends nothing either.
|
|
@@ -172,15 +231,15 @@ zoodealio api GET Properties --query pageSize=5
|
|
|
172
231
|
destructive action (accepting an offer, removing a member, deleting or rotating a
|
|
173
232
|
webhook); do not add it to make an error go away.
|
|
174
233
|
- For request body shapes run `zoodealio openapi`, which prints the partner OpenAPI document.
|
|
175
|
-
- Prefer webhooks, or polling `comping status` and `import-status`, to long waits.
|
|
176
|
-
[
|
|
234
|
+
- Prefer webhooks, or polling `comping status` and `import-status`, to long waits. The
|
|
235
|
+
[developer docs](https://zoodealio.ai/developers#webhooks) cover events, signatures and delivery.
|
|
177
236
|
|
|
178
237
|
## Limits
|
|
179
238
|
|
|
180
239
|
- No multipart uploads of local files. Photos and documents are imported from URLs only
|
|
181
240
|
(`photos add --url`); `api` cannot send multipart either.
|
|
182
|
-
- No
|
|
183
|
-
|
|
241
|
+
- No keychain or interactive prompts. Credentials are `ZOODEALIO_API_KEY` or the
|
|
242
|
+
`credentials.json` written by `zoodealio login`.
|
|
184
243
|
|
|
185
244
|
## Development
|
|
186
245
|
|
|
@@ -192,13 +251,15 @@ npm run check # eslint, tsc --noEmit (checkJs), node --test
|
|
|
192
251
|
|
|
193
252
|
## Releasing
|
|
194
253
|
|
|
195
|
-
|
|
254
|
+
npm no longer lets a token that bypasses 2FA publish directly, so releases are staged and then
|
|
255
|
+
approved by a maintainer with 2FA:
|
|
196
256
|
|
|
197
257
|
```sh
|
|
198
258
|
cd integrations/cli
|
|
199
259
|
npm version <patch|minor|major> --no-git-tag-version # in a PR, then merge
|
|
200
260
|
npm run check
|
|
201
|
-
npm publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
|
|
261
|
+
npm stage publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
|
|
202
262
|
```
|
|
203
263
|
|
|
204
|
-
|
|
264
|
+
Then approve it on npmjs.com (avatar → Staged Packages → Approve). A version with a `-` suffix
|
|
265
|
+
(`0.2.0-beta.1`) is staged with `--tag next` so `latest` stays stable.
|
package/bin/zoodealio.js
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { run } = require('../src/cli');
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Single keypresses from the terminal while `login` waits; the returned function hands the terminal back.
|
|
6
|
+
* @param {(key: string) => void} handler
|
|
7
|
+
*/
|
|
8
|
+
function onKey(handler) {
|
|
9
|
+
const stdin = process.stdin;
|
|
10
|
+
stdin.setRawMode(true);
|
|
11
|
+
stdin.setEncoding('utf8');
|
|
12
|
+
stdin.resume();
|
|
13
|
+
const listener = (/** @type {string} */ data) => {
|
|
14
|
+
for (const key of data) handler(key);
|
|
15
|
+
};
|
|
16
|
+
stdin.on('data', listener);
|
|
17
|
+
return () => {
|
|
18
|
+
stdin.off('data', listener);
|
|
19
|
+
stdin.setRawMode(false);
|
|
20
|
+
stdin.pause();
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
run(process.argv.slice(2), {
|
|
5
25
|
env: process.env,
|
|
6
26
|
stdout: process.stdout,
|
|
7
27
|
stderr: process.stderr,
|
|
8
28
|
stdin: process.stdin,
|
|
29
|
+
// Someone is at a terminal: prompt for keys. Agents and scripts never get a prompt.
|
|
30
|
+
interactive: !!process.stdin.isTTY && !!process.stderr.isTTY,
|
|
31
|
+
onKey,
|
|
9
32
|
}).then((code) => {
|
|
10
33
|
process.exitCode = code;
|
|
11
34
|
});
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -29,7 +29,7 @@ function parse(argv) {
|
|
|
29
29
|
}
|
|
30
30
|
const { values, positionals } = parsed;
|
|
31
31
|
if (values.help) return { help: helpText(positionals) };
|
|
32
|
-
if (positionals.length === 0)
|
|
32
|
+
if (positionals.length === 0) return { help: helpText([]) };
|
|
33
33
|
|
|
34
34
|
let command;
|
|
35
35
|
for (const c of COMMANDS) {
|
package/src/cli.js
CHANGED
|
@@ -2,12 +2,17 @@ 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');
|
|
9
|
+
const { createUi } = require('./ui');
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* @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
|
|
13
|
+
* stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch,
|
|
14
|
+
* openBrowser?: (url: string) => Promise<void>, copy?: (text: string) => Promise<void>, loginTimeoutMs?: number,
|
|
15
|
+
* interactive?: boolean, onKey?: (handler: (key: string) => void) => () => void}} Io
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
/**
|
|
@@ -17,20 +22,18 @@ const pkg = require('../package.json');
|
|
|
17
22
|
* @returns {Promise<number>} exit code
|
|
18
23
|
*/
|
|
19
24
|
async function run(argv, io) {
|
|
20
|
-
const
|
|
25
|
+
const envKey = (io.env.ZOODEALIO_API_KEY || '').trim();
|
|
26
|
+
/** Every key seen this run; none may appear in output. */
|
|
27
|
+
const secrets = new Set(envKey ? [envKey] : []);
|
|
21
28
|
try {
|
|
22
|
-
await execute(argv, io,
|
|
29
|
+
await execute(argv, io, envKey, secrets);
|
|
23
30
|
return 0;
|
|
24
31
|
} catch (err) {
|
|
25
32
|
const error = err instanceof CliError
|
|
26
33
|
? err
|
|
27
34
|
: 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`);
|
|
35
|
+
// A key can appear JSON-escaped (newline, quote, backslash) inside the serialized error.
|
|
36
|
+
io.stderr.write(`${scrubKeys(JSON.stringify(error.toJSON()), secrets)}\n`);
|
|
34
37
|
return error.exit;
|
|
35
38
|
}
|
|
36
39
|
}
|
|
@@ -38,9 +41,10 @@ async function run(argv, io) {
|
|
|
38
41
|
/**
|
|
39
42
|
* @param {string[]} argv
|
|
40
43
|
* @param {Io} io
|
|
41
|
-
* @param {string}
|
|
44
|
+
* @param {string} envKey
|
|
45
|
+
* @param {Set<string>} secrets
|
|
42
46
|
*/
|
|
43
|
-
async function execute(argv, io,
|
|
47
|
+
async function execute(argv, io, envKey, secrets) {
|
|
44
48
|
const parsed = parse(argv);
|
|
45
49
|
if ('help' in parsed) {
|
|
46
50
|
io.stdout.write(parsed.help);
|
|
@@ -53,6 +57,68 @@ async function execute(argv, io, key) {
|
|
|
53
57
|
|
|
54
58
|
if (command.name === 'commands') return emit(catalogue());
|
|
55
59
|
|
|
60
|
+
const origin = resolveOrigin(io.env, flags.env);
|
|
61
|
+
const stored = readCredential(io.env, origin);
|
|
62
|
+
if (stored) secrets.add(stored.key);
|
|
63
|
+
const source = envKey ? 'env' : stored ? 'login' : undefined;
|
|
64
|
+
const key = envKey || stored?.key || '';
|
|
65
|
+
|
|
66
|
+
if (['login', 'logout', 'mcp'].includes(command.name)) {
|
|
67
|
+
if (flags['dry-run']) throw usage(`--dry-run is not supported for \`${command.name}\``);
|
|
68
|
+
if (command.name === 'login') {
|
|
69
|
+
const ui = createUi(io.stderr, { fancy: !!io.interactive && !io.env.NO_COLOR });
|
|
70
|
+
const result = await browserLogin({
|
|
71
|
+
apiOrigin: origin, webOrigin: resolveWebOrigin(io.env, flags.env), device: (flags.name?.trim() || defaultDevice()).slice(0, 64),
|
|
72
|
+
envName: flags.env || 'prod', noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser, copy: io.copy,
|
|
73
|
+
interactive: io.interactive, onKey: io.onKey, timeoutMs: io.loginTimeoutMs, ui, fetch: io.fetch,
|
|
74
|
+
});
|
|
75
|
+
secrets.add(result.key);
|
|
76
|
+
const file = saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
|
|
77
|
+
const dev = flags.env && flags.env !== 'prod' ? ` --env ${flags.env}` : '';
|
|
78
|
+
const home = io.env.HOME || io.env.USERPROFILE;
|
|
79
|
+
const where = home && file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
80
|
+
const { style } = ui;
|
|
81
|
+
const next = [
|
|
82
|
+
[`zoodealio whoami${dev}`, 'check who you\'re signed in as'],
|
|
83
|
+
[`zoodealio properties list${dev}`, 'your first call'],
|
|
84
|
+
[`zoodealio mcp --help`, 'connect an AI client'],
|
|
85
|
+
['zoodealio logout', 'sign this computer out'],
|
|
86
|
+
];
|
|
87
|
+
const width = Math.max(...next.map(([c]) => c.length)) + 3;
|
|
88
|
+
ui.say();
|
|
89
|
+
ui.say(`${style.green('✓')} Signed in as ${style.bold(result.email)}${dev ? ` ${style.dim(`(${flags.env})`)}` : ''}`);
|
|
90
|
+
ui.say(` ${style.dim(`Key "${result.name}" saved to ${where}`)}`);
|
|
91
|
+
ui.say();
|
|
92
|
+
ui.say(` ${style.bold('Next')}`);
|
|
93
|
+
for (const [cmd, what] of next) ui.say(` ${cmd.padEnd(width)}${style.dim(what)}`);
|
|
94
|
+
ui.say();
|
|
95
|
+
// A person at a terminal has just read all that; the JSON is for scripts and agents.
|
|
96
|
+
if (io.interactive && io.stdout.isTTY) return;
|
|
97
|
+
return emit({ ok: true, email: result.email, name: result.name, keyPrefix: result.keyPrefix, origin });
|
|
98
|
+
}
|
|
99
|
+
if (command.name === 'logout') {
|
|
100
|
+
if (!stored) {
|
|
101
|
+
return emit({
|
|
102
|
+
ok: true, loggedOut: false,
|
|
103
|
+
detail: envKey ? 'Signed in only through ZOODEALIO_API_KEY; nothing to sign out of.' : 'Not signed in.',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await send(io.fetch || fetch, {
|
|
108
|
+
method: 'DELETE', url: buildUrl(origin, `/openapi/Keys/${encodeURIComponent(stored.keyId)}`, []),
|
|
109
|
+
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT, ApiKey: stored.key },
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// 401/404 mean the key is already gone; anything else keeps the login so it can be retried.
|
|
113
|
+
if (!(err instanceof CliError) || (err.status !== 401 && err.status !== 404)) throw err;
|
|
114
|
+
}
|
|
115
|
+
removeCredential(io.env, origin);
|
|
116
|
+
return emit({ ok: true, loggedOut: true, email: stored.email, origin });
|
|
117
|
+
}
|
|
118
|
+
if (!key) throw notSignedIn();
|
|
119
|
+
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' });
|
|
120
|
+
}
|
|
121
|
+
|
|
56
122
|
/** @type {string} */
|
|
57
123
|
let method;
|
|
58
124
|
/** @type {string} */
|
|
@@ -90,14 +156,13 @@ async function execute(argv, io, key) {
|
|
|
90
156
|
}
|
|
91
157
|
|
|
92
158
|
const needsKey = command.auth !== false;
|
|
93
|
-
if (needsKey && !key && !flags['dry-run']) throw
|
|
159
|
+
if (needsKey && !key && !flags['dry-run']) throw notSignedIn();
|
|
94
160
|
|
|
95
|
-
const origin = resolveOrigin(io.env, flags.env);
|
|
96
161
|
const url = buildUrl(origin, path, query);
|
|
97
162
|
/** @param {string} apiKey */
|
|
98
163
|
const headers = (apiKey) => ({
|
|
99
164
|
Accept: 'application/json',
|
|
100
|
-
'User-Agent':
|
|
165
|
+
'User-Agent': USER_AGENT,
|
|
101
166
|
...(needsKey ? { ApiKey: apiKey } : {}),
|
|
102
167
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
103
168
|
...(flags['if-match'] === undefined ? {} : { 'If-Match': flags['if-match'] }),
|
|
@@ -105,9 +170,23 @@ async function execute(argv, io, key) {
|
|
|
105
170
|
|
|
106
171
|
if (flags['dry-run']) return emit({ method, url, headers: headers(redactKey(key)), body: body ?? null });
|
|
107
172
|
|
|
108
|
-
|
|
109
|
-
|
|
173
|
+
/** @type {unknown} */
|
|
174
|
+
let result;
|
|
175
|
+
try {
|
|
176
|
+
result = await send(io.fetch || fetch, { method, url, headers: headers(key), body });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (source === 'login' && err instanceof CliError && err.status === 401) {
|
|
179
|
+
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.' });
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
if (command.name === 'keys revoke' && source === 'login' && stored && args.id === stored.keyId) removeCredential(io.env, origin);
|
|
184
|
+
if (command.name === 'whoami') return emit({ ok: true, origin, key: redactKey(key), source, ...(source === 'login' && stored ? { email: stored.email } : {}) });
|
|
110
185
|
return emit(result);
|
|
111
186
|
}
|
|
112
187
|
|
|
188
|
+
function notSignedIn() {
|
|
189
|
+
return new CliError({ exit: 3, code: 'unauthorized', title: 'Not signed in', detail: 'Not signed in. Run zoodealio login, or set ZOODEALIO_API_KEY.' });
|
|
190
|
+
}
|
|
191
|
+
|
|
113
192
|
module.exports = { run };
|
package/src/commands.js
CHANGED
|
@@ -7,7 +7,7 @@ const { parseJsonArg } = require('./input');
|
|
|
7
7
|
* @typedef {{name: string, description: string, guid?: boolean}} Arg
|
|
8
8
|
* @typedef {{name: string, summary: string, method: string | null, path: string | null, args: Arg[],
|
|
9
9
|
* flags: Flag[], body?: 'required' | 'optional', convenience?: string[], destructive?: boolean,
|
|
10
|
-
* auth?: boolean, build?: (flags: Record<string, any>) => unknown}} Command
|
|
10
|
+
* auth?: boolean, build?: (flags: Record<string, any>) => unknown, notes?: string[]}} Command
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -135,6 +135,37 @@ 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
|
+
notes: [
|
|
141
|
+
'Opens the Zoodealio sign-in page in your browser. Sign in the way you usually do and click',
|
|
142
|
+
'Authorize; this terminal then finishes by itself. In a terminal, press Enter to open it',
|
|
143
|
+
'again, c to copy the link, or q to cancel. The link is always printed too, for when no browser',
|
|
144
|
+
'can be opened (SSH, containers: use --no-browser and open it on another device).',
|
|
145
|
+
'The key is saved for this computer; zoodealio logout removes it.',
|
|
146
|
+
'On CI or a server, set ZOODEALIO_API_KEY instead.',
|
|
147
|
+
],
|
|
148
|
+
flags: [
|
|
149
|
+
{ name: 'no-browser', type: 'boolean', description: 'Print the sign-in URL to stderr instead of opening a browser' },
|
|
150
|
+
str('name', 'Device name shown at sign-in (default: this host)'),
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
153
|
+
cmd('logout', 'Revoke the stored CLI key and forget it', 'DELETE', '/openapi/Keys/{keyId}'),
|
|
154
|
+
cmd('keys list', 'List your API keys (needs a zoodealio login)', 'GET', '/openapi/Keys'),
|
|
155
|
+
cmd('keys create', 'Create an API key; the raw key is shown once', 'POST', '/openapi/Keys', {
|
|
156
|
+
flags: [str('name', 'Key name', { required: true })],
|
|
157
|
+
build: (f) => ({ name: f.name }),
|
|
158
|
+
}),
|
|
159
|
+
cmd('keys revoke', 'Revoke an API key', 'DELETE', '/openapi/Keys/{id}', { args: [id('id', 'Key id')], destructive: true }),
|
|
160
|
+
cmd('mcp', 'Run a stdio MCP server bridged to /openapi/mcp', 'POST', '/openapi/mcp', {
|
|
161
|
+
notes: [
|
|
162
|
+
'Uses your zoodealio login (or ZOODEALIO_API_KEY). Add it to an AI client:',
|
|
163
|
+
' Claude Code: claude mcp add --scope user zoodealio -- zoodealio mcp',
|
|
164
|
+
' Claude Desktop,',
|
|
165
|
+
' Cursor, VS Code: {"command": "zoodealio", "args": ["mcp"]}',
|
|
166
|
+
'Add --env dev to the args for the dev environment.',
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
138
169
|
cmd('whoami', 'Check the API key and show the target origin', 'GET', '/openapi/Webhooks/event-types'),
|
|
139
170
|
cmd('openapi', 'Print the partner OpenAPI document (no key needed)', 'GET', '/_openapi/partner.json', { auth: false }),
|
|
140
171
|
cmd('api', 'Call any /openapi endpoint directly (DELETE needs --yes)', null, null, {
|
|
@@ -193,8 +224,21 @@ function helpText(prefix) {
|
|
|
193
224
|
if (exact.destructive) lines.push('Destructive: requires --yes.');
|
|
194
225
|
for (const a of exact.args) lines.push(` <${a.name}> ${a.description}`);
|
|
195
226
|
for (const f of exact.flags) lines.push(` --${f.name} ${f.description}${f.required ? ' (required)' : ''}`);
|
|
227
|
+
if (exact.notes) lines.push('', ...exact.notes);
|
|
196
228
|
} else {
|
|
197
|
-
lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr.
|
|
229
|
+
lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr. Sign in with `zoodealio login` or set ZOODEALIO_API_KEY.', '');
|
|
230
|
+
if (prefix.length === 0) {
|
|
231
|
+
lines.push(
|
|
232
|
+
'Get started:',
|
|
233
|
+
' zoodealio login Sign in through your browser',
|
|
234
|
+
' zoodealio whoami Check who you\'re signed in as',
|
|
235
|
+
' zoodealio properties list Your first call',
|
|
236
|
+
' zoodealio mcp --help Connect an AI client (Claude, Cursor, VS Code)',
|
|
237
|
+
' zoodealio commands Every command as JSON, for AI agents',
|
|
238
|
+
'',
|
|
239
|
+
'Commands:',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
198
242
|
for (const c of matches) lines.push(` ${c.name.padEnd(28)}${c.summary}`);
|
|
199
243
|
lines.push('', 'Global flags:');
|
|
200
244
|
for (const f of GLOBAL_FLAGS) lines.push(` --${f.name.padEnd(10)}${f.description}`);
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
* @returns {string} the credentials file
|
|
73
|
+
*/
|
|
74
|
+
function saveCredential(env, origin, credential) {
|
|
75
|
+
writeAll(env, { ...readAll(env, true), [origin]: credential });
|
|
76
|
+
return path.join(configDir(env), 'credentials.json');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Record<string, string | undefined>} env
|
|
81
|
+
* @param {string} origin
|
|
82
|
+
*/
|
|
83
|
+
function removeCredential(env, origin) {
|
|
84
|
+
const all = readAll(env, true);
|
|
85
|
+
delete all[origin];
|
|
86
|
+
writeAll(env, all);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
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,212 @@
|
|
|
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
|
+
/**
|
|
41
|
+
* Puts text on the system clipboard with the first tool this machine has. Rejects when none works.
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async function copyToClipboard(text) {
|
|
46
|
+
const tools = process.platform === 'darwin' ? [['pbcopy', []]]
|
|
47
|
+
: process.platform === 'win32' ? [['clip', []]]
|
|
48
|
+
: [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
|
|
49
|
+
for (const [command, args] of /** @type {[string, string[]][]} */ (tools)) {
|
|
50
|
+
const ok = await new Promise((resolve) => {
|
|
51
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
52
|
+
child.once('error', () => resolve(false));
|
|
53
|
+
child.once('close', (code) => resolve(code === 0));
|
|
54
|
+
child.stdin.end(text);
|
|
55
|
+
});
|
|
56
|
+
if (ok) return;
|
|
57
|
+
}
|
|
58
|
+
throw new Error('no clipboard tool');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
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>`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Listens on a loopback port for the browser's one redirect back to /callback.
|
|
65
|
+
* @param {string} state
|
|
66
|
+
* @param {number} timeoutMs
|
|
67
|
+
* @returns {Promise<{port: number, result: Promise<{code?: string, error?: string}>, close: () => void}>}
|
|
68
|
+
*/
|
|
69
|
+
async function listenForCallback(state, timeoutMs) {
|
|
70
|
+
/** @type {(r: {code?: string, error?: string}) => void} */
|
|
71
|
+
let finish = () => {};
|
|
72
|
+
/** @type {(e: Error) => void} */
|
|
73
|
+
let fail = () => {};
|
|
74
|
+
const result = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
|
|
75
|
+
const stateBuffer = Buffer.from(state);
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
78
|
+
const got = Buffer.from(url.searchParams.get('state') || '');
|
|
79
|
+
res.setHeader('Connection', 'close');
|
|
80
|
+
if (req.method !== 'GET' || url.pathname !== '/callback') {
|
|
81
|
+
res.writeHead(404).end();
|
|
82
|
+
} else if (got.length !== stateBuffer.length || !crypto.timingSafeEqual(got, stateBuffer)) {
|
|
83
|
+
// A stray or forged request must not be able to abort the real login.
|
|
84
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in link did not match'));
|
|
85
|
+
} else if (url.searchParams.get('error')) {
|
|
86
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Sign-in cancelled'));
|
|
87
|
+
finish({ error: /** @type {string} */ (url.searchParams.get('error')) });
|
|
88
|
+
} else if (url.searchParams.get('code')) {
|
|
89
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE('Signed in to the Zoodealio CLI'));
|
|
90
|
+
finish({ code: /** @type {string} */ (url.searchParams.get('code')) });
|
|
91
|
+
} else {
|
|
92
|
+
res.writeHead(400).end();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await new Promise((resolve, reject) => {
|
|
96
|
+
server.once('error', reject);
|
|
97
|
+
server.listen(0, '127.0.0.1', () => resolve(undefined));
|
|
98
|
+
});
|
|
99
|
+
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);
|
|
100
|
+
return {
|
|
101
|
+
port: /** @type {import('node:net').AddressInfo} */ (server.address()).port,
|
|
102
|
+
result,
|
|
103
|
+
close: () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
server.close();
|
|
106
|
+
server.closeIdleConnections();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Browser sign-in (authorization code + PKCE). Returns the exchange response; the caller stores it.
|
|
113
|
+
* Everything it says goes to stderr, so stdout stays JSON. In a terminal someone is typing in
|
|
114
|
+
* (`interactive` with `onKey`), Enter opens the browser, c copies the link and q cancels; otherwise the
|
|
115
|
+
* browser opens straight away. The link is always printed, in case no browser can be opened.
|
|
116
|
+
* @param {{apiOrigin: string, webOrigin: string, device: string, envName: string, noBrowser: boolean,
|
|
117
|
+
* openBrowser?: (url: string) => Promise<void>, copy?: (text: string) => Promise<void>,
|
|
118
|
+
* interactive?: boolean, onKey?: (handler: (key: string) => void) => () => void,
|
|
119
|
+
* timeoutMs?: number, ui: ReturnType<typeof import('./ui').createUi>, fetch?: typeof fetch}} opts
|
|
120
|
+
* @returns {Promise<{key: string, keyId: string, keyPrefix: string, name: string, email: string}>}
|
|
121
|
+
*/
|
|
122
|
+
async function browserLogin(opts) {
|
|
123
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
124
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
125
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
126
|
+
const listener = await listenForCallback(state, opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
|
|
127
|
+
try {
|
|
128
|
+
const url = `${opts.webOrigin}/cli/login?${new URLSearchParams({ port: String(listener.port), state, challenge, device: opts.device })}`;
|
|
129
|
+
const { ui } = opts;
|
|
130
|
+
const { style } = ui;
|
|
131
|
+
const minutes = Math.max(1, Math.round((opts.timeoutMs ?? LOGIN_TIMEOUT_MS) / 60000));
|
|
132
|
+
const open = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await (opts.openBrowser || openBrowser)(url);
|
|
135
|
+
ui.say(` ${style.dim('Opened your browser. Nothing there? Use the link above, or press Enter.')}`);
|
|
136
|
+
} catch {
|
|
137
|
+
ui.say(` ${style.red('Couldn\'t open a browser.')} Copy the link above into one.`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const prompting = !opts.noBrowser && !!opts.interactive && !!opts.onKey;
|
|
141
|
+
|
|
142
|
+
ui.say();
|
|
143
|
+
ui.say(` ${style.bold(`Sign in to Zoodealio${opts.envName === 'prod' ? '' : ` (${opts.envName})`}`)}`);
|
|
144
|
+
ui.say();
|
|
145
|
+
ui.say(opts.noBrowser ? ' 1. Open the link below in a browser, on this or any device.'
|
|
146
|
+
: ' 1. Your browser opens the sign-in page. If it doesn\'t, use the link below.');
|
|
147
|
+
ui.say(` 2. Sign in the way you usually do, then click ${style.bold('Authorize')} for "${opts.device}".`);
|
|
148
|
+
ui.say(' 3. Come back here. This finishes by itself.');
|
|
149
|
+
ui.say();
|
|
150
|
+
ui.say(` ${style.dim('Link:')} ${style.link(url)}`);
|
|
151
|
+
if (prompting) ui.say(` ${style.dim('Press Enter to open it again · c to copy the link · q to cancel')}`);
|
|
152
|
+
ui.say();
|
|
153
|
+
|
|
154
|
+
/** @type {() => void} */
|
|
155
|
+
let stopKeys = () => {};
|
|
156
|
+
/** @type {Promise<{code?: string, error?: string}>} */
|
|
157
|
+
let cancelled = new Promise(() => {});
|
|
158
|
+
if (!opts.noBrowser) await open();
|
|
159
|
+
if (prompting) {
|
|
160
|
+
cancelled = new Promise((resolve) => {
|
|
161
|
+
stopKeys = /** @type {(handler: (key: string) => void) => () => void} */ (opts.onKey)((key) => {
|
|
162
|
+
if (key === '\r' || key === '\n') {
|
|
163
|
+
void open();
|
|
164
|
+
} else if (key === 'c' || key === 'C') {
|
|
165
|
+
(opts.copy || copyToClipboard)(url).then(
|
|
166
|
+
() => ui.say(` ${style.green('✓')} Link copied. Paste it into your browser.`),
|
|
167
|
+
() => ui.say(` ${style.red('Couldn\'t reach the clipboard.')} Select the link above to copy it.`),
|
|
168
|
+
);
|
|
169
|
+
} else if (key === 'q' || key === 'Q' || key === '\u0003') {
|
|
170
|
+
resolve({ error: 'cancelled' });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
ui.spin(`Waiting for you to authorize in the browser… ${style.dim(`(the link works for ${minutes} minute${minutes === 1 ? '' : 's'})`)}`);
|
|
176
|
+
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = await Promise.race([listener.result, cancelled]);
|
|
180
|
+
} finally {
|
|
181
|
+
stopKeys();
|
|
182
|
+
ui.done();
|
|
183
|
+
}
|
|
184
|
+
const { code, error } = result;
|
|
185
|
+
if (!code) {
|
|
186
|
+
throw new CliError({
|
|
187
|
+
exit: 1, code: error === 'cancelled' ? 'login_cancelled' : 'login_failed',
|
|
188
|
+
title: error === 'cancelled' ? 'Login cancelled' : 'Login failed',
|
|
189
|
+
detail: error === 'cancelled' ? 'Sign-in was cancelled.' : `The browser reported: ${error}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
ui.say(`${ui.style.green('✓')} Authorized in the browser`);
|
|
193
|
+
ui.spin('Finishing sign-in…');
|
|
194
|
+
try {
|
|
195
|
+
return /** @type {any} */ (await send(opts.fetch || fetch, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
url: `${opts.apiOrigin}/openapi/cli-logins/exchange`,
|
|
198
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
199
|
+
body: { code, verifier },
|
|
200
|
+
}));
|
|
201
|
+
} finally {
|
|
202
|
+
ui.done();
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
listener.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @returns {string} */
|
|
210
|
+
const defaultDevice = () => os.hostname().slice(0, 64) || 'cli';
|
|
211
|
+
|
|
212
|
+
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 };
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person sees on stderr while `login` runs. In a terminal it has color and a spinner; for
|
|
3
|
+
* agents, scripts and NO_COLOR it is the same text, plain, one line per message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{write(s: string): unknown}} stream
|
|
10
|
+
* @param {{fancy: boolean}} options fancy = an interactive terminal that allows color
|
|
11
|
+
*/
|
|
12
|
+
function createUi(stream, { fancy }) {
|
|
13
|
+
/** @param {string} code @returns {(s: string) => string} */
|
|
14
|
+
const paint = (code) => (s) => (fancy ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
15
|
+
const style = { bold: paint('1'), dim: paint('2'), green: paint('32'), cyan: paint('36'), red: paint('31'), link: paint('4;36') };
|
|
16
|
+
|
|
17
|
+
/** @type {{text: string, timer: NodeJS.Timeout, frame: number} | null} */
|
|
18
|
+
let spinner = null;
|
|
19
|
+
const draw = () => {
|
|
20
|
+
if (!spinner) return;
|
|
21
|
+
stream.write(`\r\u001b[2K${style.cyan(FRAMES[spinner.frame % FRAMES.length])} ${spinner.text}`);
|
|
22
|
+
spinner.frame++;
|
|
23
|
+
};
|
|
24
|
+
const clear = () => {
|
|
25
|
+
if (spinner) stream.write('\r\u001b[2K');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Prints a line, keeping any spinner on the line below it. @param {string} [line] */
|
|
29
|
+
const say = (line = '') => {
|
|
30
|
+
clear();
|
|
31
|
+
stream.write(`${line}\n`);
|
|
32
|
+
draw();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
style,
|
|
37
|
+
say,
|
|
38
|
+
/** @param {string} text */
|
|
39
|
+
spin(text) {
|
|
40
|
+
if (!fancy) {
|
|
41
|
+
stream.write(`${text}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
spinner = { text, frame: 0, timer: setInterval(draw, 80) };
|
|
45
|
+
draw();
|
|
46
|
+
},
|
|
47
|
+
/** Stops the spinner, replacing it with a final line. @param {string} [line] */
|
|
48
|
+
done(line) {
|
|
49
|
+
if (spinner) {
|
|
50
|
+
clearInterval(spinner.timer);
|
|
51
|
+
clear();
|
|
52
|
+
spinner = null;
|
|
53
|
+
}
|
|
54
|
+
if (line) stream.write(`${line}\n`);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { createUi };
|