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 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 (`Mcp/OpenApiMcpTools.cs`) and the
6
- [Zapier app](../zapier/README.md). It adds no API surface: every command is one
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
- 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` 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
- ## Authenticate
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
- 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.
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 whoami` confirms the key works and shows the target origin.
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`, missing key, or HTTP 400 | `usage`, `bad_request` |
74
- | 3 | Not authenticated or not allowed (401, 403) | `unauthorized`, `forbidden` |
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
- | `whoami` | | Key check; prints `{ok, origin, key}` |
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. See
176
- [OPEN_API_WEBHOOKS.md](../../docs/OPEN_API_WEBHOOKS.md) for events, signatures and delivery.
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 config file, keychain or interactive prompts; the environment variable is the only
183
- credential source.
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
- Maintainers publish from their machine with a granular npm token kept outside the repo:
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
- A version with a `-` suffix (`0.2.0-beta.1`) goes out with `--tag next` so `latest` stays stable.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zoodealio",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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/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) throw usage('missing command; run `zoodealio --help`');
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 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');
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}} Io
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 key = (io.env.ZOODEALIO_API_KEY || '').trim();
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, key);
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
- 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`);
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} key
44
+ * @param {string} envKey
45
+ * @param {Set<string>} secrets
42
46
  */
43
- async function execute(argv, io, key) {
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 usage('ZOODEALIO_API_KEY is not set');
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': `zoodealio-cli/${pkg.version}`,
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
- 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) });
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. Key: ZOODEALIO_API_KEY.', '');
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 };