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 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
- npx zoodealio whoami
14
+ npm install -g zoodealio
15
+ zoodealio login
16
+ zoodealio whoami
17
17
  ```
18
18
 
19
- or install it globally:
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
- npm install -g zoodealio
23
- zoodealio whoami
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
- From a checkout of this repo, `node integrations/cli/bin/zoodealio.js` runs the working copy.
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
- ## Authenticate
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
- The key is read only from this variable and sent only as the `ApiKey` header. It is never
37
- printed: errors, `--dry-run` output and `whoami` show it as `zoo_…<last4>`. Redirects are
38
- refused so the header cannot be forwarded. A missing key is a usage error (exit 2) and no
39
- request is sent. The key keeps its owner's workspace and property scope.
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 whoami` confirms the key works and shows the target origin.
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`, missing key, or HTTP 400 | `usage`, `bad_request` |
74
- | 3 | Not authenticated or not allowed (401, 403) | `unauthorized`, `forbidden` |
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
- | `whoami` | | Key check; prints `{ok, origin, key}` |
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 config file, keychain or interactive prompts; the environment variable is the only
183
- credential source.
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
- Maintainers publish from their machine with a granular npm token kept outside the repo:
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
- A version with a `-` suffix (`0.2.0-beta.1`) goes out with `--tag next` so `latest` stays stable.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zoodealio",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Zoodealio command line tool: the partner API as JSON, built for people, scripts and AI agents",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
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 pkg = require('../package.json');
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}} Io
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 key = (io.env.ZOODEALIO_API_KEY || '').trim();
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, key);
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
- let text = JSON.stringify(error.toJSON());
29
- if (key) {
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} key
42
+ * @param {string} envKey
43
+ * @param {Set<string>} secrets
42
44
  */
43
- async function execute(argv, io, key) {
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 usage('ZOODEALIO_API_KEY is not set');
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': `zoodealio-cli/${pkg.version}`,
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
- const result = await send(io.fetch || fetch, { method, url, headers: headers(key), body });
109
- if (command.name === 'whoami') return emit({ ok: true, origin, key: redactKey(key) });
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. Key: ZOODEALIO_API_KEY.', '');
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 };