zoodealio 0.2.0 → 0.3.0-dev.1603

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,8 +2,8 @@
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.
@@ -17,8 +17,7 @@ zoodealio whoami
17
17
  ```
18
18
 
19
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.
20
+ `ZOODEALIO_API_KEY` set).
22
21
 
23
22
  ## Authenticate
24
23
 
@@ -31,15 +30,19 @@ zoodealio login --name work-laptop # device name shown at sign-in (default: th
31
30
  zoodealio logout # revokes this machine's key and forgets it
32
31
  ```
33
32
 
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.
33
+ `login` opens your browser on the sign-in page, explains the steps and prints the link. In a
34
+ terminal, press **Enter** to open it again, **c** to copy the link, or **q** to cancel; the link also
35
+ works pasted into any browser, on this or another device. Sign in the way you usually do and click **Authorize**; the terminal
36
+ finishes by itself and suggests what to run next. Run by a script or an agent (no terminal), it
37
+ opens the browser the same way and prints the link, without the key prompt. All of this goes to stderr.
38
+
39
+ Under the hood it binds a loopback port, sends you to Zoodealio AI to approve, and exchanges the
40
+ one-time code (with PKCE) for a personal API key named `CLI – <device>`. It waits up to 5 minutes.
41
+ It prints `{ok, email, name, keyPrefix, origin}` on stdout and never the key.
37
42
 
38
43
  The key is stored per API origin in `$XDG_CONFIG_HOME/zoodealio/credentials.json`
39
44
  (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.
45
+ written atomically with mode `0600` in a `0700` directory.
43
46
 
44
47
  ### API key for CI and servers
45
48
 
@@ -90,17 +93,6 @@ Cursor (`.cursor/mcp.json`) or VS Code (`.vscode/mcp.json`, under `servers`):
90
93
  Run `zoodealio login` first (or put `ZOODEALIO_API_KEY` in the client's `env`). Stdout carries only
91
94
  JSON-RPC; logs go to stderr.
92
95
 
93
- ## Environments
94
-
95
- | Selection | API origin |
96
- | --- | --- |
97
- | default | `https://api.zoodealio.ai` |
98
- | `--env dev` | `https://dev.api.zoodealio.ai` |
99
- | `ZOODEALIO_API_URL=http://localhost:5256` | that origin (overrides both) |
100
-
101
- Keys are per environment. Use a local API URL only for a local API; the port above is an
102
- example, use whichever yours runs on.
103
-
104
96
  ## Output and errors
105
97
 
106
98
  stdout carries JSON and nothing else: pretty-printed on a terminal, compact when piped
@@ -132,7 +124,6 @@ Route IDs are GUIDs and are checked before any request is made.
132
124
 
133
125
  | Flag | Effect |
134
126
  | --- | --- |
135
- | `--env prod\|dev` | Pick the environment (default `prod`) |
136
127
  | `--pretty` | Pretty-print JSON even when piped |
137
128
  | `--dry-run` | Print `{method, url, headers, body}` with the key redacted and send nothing; works without a key |
138
129
  | `--yes` | Confirm a destructive command (not needed with `--dry-run`) |
@@ -151,7 +142,7 @@ concurrency on `properties update`, `offers accept` and `offers request-details`
151
142
  | --- | --- | --- |
152
143
  | `properties list` | GET Properties | `--search`, `--state` |
153
144
  | `properties get <id>` | GET Properties/{id} | |
154
- | `properties create` | POST Properties | `--body` |
145
+ | `properties create` | POST Properties | `--body`; `--agent <id>` assigns a team member or a pending invite |
155
146
  | `properties update <id>` | PUT Properties/{id} | `--body`, `--if-match`; replaces wholesale |
156
147
  | `properties photos list\|add\|delete` | photos | `add`: `--url` (repeatable) or `--body`; `delete <id> <photoId>` needs `--yes` |
157
148
  | `properties documents list\|add\|delete` | documents | same shape as photos |
@@ -163,7 +154,7 @@ concurrency on `properties update`, `offers accept` and `offers request-details`
163
154
  | `transactions get <id>` | GET Transactions/{id} | |
164
155
  | `transactions active` | GET Transactions/active | `--property` required |
165
156
  | `comping status <transactionId>` | GET Transactions/{id}/comping | |
166
- | `team members`, `team get <id>`, `teams list` | GET Team | `--search` |
157
+ | `team members`, `team get <id>`, `teams list` | GET Team | `--search`; `team members` also lists pending invites (`kind: "invite"`) |
167
158
  | `team invite` | POST Team/members | `--email` (+ `--first-name`, `--last-name`, `--team`, `--admin-team`) or `--body` |
168
159
  | `team remove <id>` | DELETE Team/members/{id} | `--yes` required |
169
160
  | `webhooks event-types\|list\|deliveries` | GET Webhooks | `deliveries`: `--subscription`, `--status` |
@@ -225,8 +216,8 @@ zoodealio api GET Properties --query pageSize=5
225
216
  destructive action (accepting an offer, removing a member, deleting or rotating a
226
217
  webhook); do not add it to make an error go away.
227
218
  - For request body shapes run `zoodealio openapi`, which prints the partner OpenAPI document.
228
- - Prefer webhooks, or polling `comping status` and `import-status`, to long waits. See
229
- [OPEN_API_WEBHOOKS.md](../../docs/OPEN_API_WEBHOOKS.md) for events, signatures and delivery.
219
+ - Prefer webhooks, or polling `comping status` and `import-status`, to long waits. The
220
+ [developer docs](https://zoodealio.ai/developers#webhooks) cover events, signatures and delivery.
230
221
 
231
222
  ## Limits
232
223
 
@@ -235,25 +226,3 @@ zoodealio api GET Properties --query pageSize=5
235
226
  - No keychain or interactive prompts. Credentials are `ZOODEALIO_API_KEY` or the
236
227
  `credentials.json` written by `zoodealio login`.
237
228
 
238
- ## Development
239
-
240
- ```sh
241
- cd integrations/cli
242
- npm install
243
- npm run check # eslint, tsc --noEmit (checkJs), node --test
244
- ```
245
-
246
- ## Releasing
247
-
248
- npm no longer lets a token that bypasses 2FA publish directly, so releases are staged and then
249
- approved by a maintainer with 2FA:
250
-
251
- ```sh
252
- cd integrations/cli
253
- npm version <patch|minor|major> --no-git-tag-version # in a PR, then merge
254
- npm run check
255
- npm stage publish --userconfig ~/.config/zoodealio/npm-publish.npmrc
256
- ```
257
-
258
- Then approve it on npmjs.com (avatar → Staged Packages → Approve). A version with a `-` suffix
259
- (`0.2.0-beta.1`) is staged with `--tag next` so `latest` stays stable.
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.2.0",
3
+ "version": "0.3.0-dev.1603",
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,15 +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 { USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send } = require('./http');
5
+ const { DEFAULT_ENV, USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send } = require('./http');
6
6
  const { readCredential, saveCredential, removeCredential } = require('./credentials');
7
7
  const { browserLogin, resolveWebOrigin, defaultDevice } = require('./login');
8
8
  const { bridge } = require('./mcp');
9
+ const { createUi } = require('./ui');
9
10
 
10
11
  /**
11
12
  * @typedef {{env: Record<string, string | undefined>, stdout: {write(s: string): unknown, isTTY?: boolean},
12
13
  * stderr: {write(s: string): unknown}, stdin: AsyncIterable<string | Buffer>, fetch?: typeof fetch,
13
- * openBrowser?: (url: string) => Promise<void>, loginTimeoutMs?: number}} Io
14
+ * openBrowser?: (url: string) => Promise<void>, copy?: (text: string) => Promise<void>, loginTimeoutMs?: number,
15
+ * interactive?: boolean, onKey?: (handler: (key: string) => void) => () => void}} Io
14
16
  */
15
17
 
16
18
  /**
@@ -55,7 +57,8 @@ async function execute(argv, io, envKey, secrets) {
55
57
 
56
58
  if (command.name === 'commands') return emit(catalogue());
57
59
 
58
- const origin = resolveOrigin(io.env, flags.env);
60
+ const envName = flags.env || DEFAULT_ENV;
61
+ const origin = resolveOrigin(io.env, envName);
59
62
  const stored = readCredential(io.env, origin);
60
63
  if (stored) secrets.add(stored.key);
61
64
  const source = envKey ? 'env' : stored ? 'login' : undefined;
@@ -64,12 +67,35 @@ async function execute(argv, io, envKey, secrets) {
64
67
  if (['login', 'logout', 'mcp'].includes(command.name)) {
65
68
  if (flags['dry-run']) throw usage(`--dry-run is not supported for \`${command.name}\``);
66
69
  if (command.name === 'login') {
70
+ const ui = createUi(io.stderr, { fancy: !!io.interactive && !io.env.NO_COLOR });
67
71
  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,
72
+ apiOrigin: origin, webOrigin: resolveWebOrigin(io.env, envName), device: (flags.name?.trim() || defaultDevice()).slice(0, 64),
73
+ envName, noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser, copy: io.copy,
74
+ interactive: io.interactive, onKey: io.onKey, timeoutMs: io.loginTimeoutMs, ui, fetch: io.fetch,
70
75
  });
71
76
  secrets.add(result.key);
72
- saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
77
+ const file = saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
78
+ // The hints run without --env only where that reaches the environment just signed in to.
79
+ const dev = envName === DEFAULT_ENV ? '' : ` --env ${envName}`;
80
+ const home = io.env.HOME || io.env.USERPROFILE;
81
+ const where = home && file.startsWith(home) ? `~${file.slice(home.length)}` : file;
82
+ const { style } = ui;
83
+ const next = [
84
+ [`zoodealio whoami${dev}`, 'check who you\'re signed in as'],
85
+ [`zoodealio properties list${dev}`, 'your first call'],
86
+ [`zoodealio mcp --help`, 'connect an AI client'],
87
+ ['zoodealio logout', 'sign this computer out'],
88
+ ];
89
+ const width = Math.max(...next.map(([c]) => c.length)) + 3;
90
+ ui.say();
91
+ ui.say(`${style.green('✓')} Signed in as ${style.bold(result.email)}${envName === 'prod' ? '' : ` ${style.dim(`(${envName})`)}`}`);
92
+ ui.say(` ${style.dim(`Key "${result.name}" saved to ${where}`)}`);
93
+ ui.say();
94
+ ui.say(` ${style.bold('Next')}`);
95
+ for (const [cmd, what] of next) ui.say(` ${cmd.padEnd(width)}${style.dim(what)}`);
96
+ ui.say();
97
+ // A person at a terminal has just read all that; the JSON is for scripts and agents.
98
+ if (io.interactive && io.stdout.isTTY) return;
73
99
  return emit({ ok: true, email: result.email, name: result.name, keyPrefix: result.keyPrefix, origin });
74
100
  }
75
101
  if (command.name === 'logout') {
@@ -124,6 +150,11 @@ async function execute(argv, io, envKey, secrets) {
124
150
  if (flags.body !== undefined) {
125
151
  if ((command.convenience || []).some((n) => flags[n] !== undefined)) throw usage('use either --body or the convenience flags, not both');
126
152
  body = await resolveBody(flags.body, io.stdin);
153
+ const extra = command.overlay && command.overlay(flags);
154
+ if (extra && Object.keys(extra).length) {
155
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) throw usage('--body must be a JSON object when combined with convenience flags');
156
+ body = { ...body, ...extra };
157
+ }
127
158
  } else if (command.build) {
128
159
  body = command.build(flags);
129
160
  if (body === undefined && command.body === 'required') throw usage('provide --body or the convenience flags');
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, overlay?: (flags: Record<string, any>) => Record<string, unknown>, notes?: string[]}} Command
11
11
  */
12
12
 
13
13
  /**
@@ -50,7 +50,14 @@ const COMMANDS = [
50
50
  flags: [str('search', 'Free-text search', { query: 'search' }), str('state', 'Two-letter state code', { query: 'stateCode' }), ...PAGING],
51
51
  }),
52
52
  cmd('properties get', 'Get one property', 'GET', '/openapi/Properties/{id}', { args: [id('id', 'Property id')] }),
53
- cmd('properties create', 'Create a property', 'POST', '/openapi/Properties', { body: 'required', flags: [BODY] }),
53
+ cmd('properties create', 'Create a property', 'POST', '/openapi/Properties', {
54
+ body: 'required',
55
+ flags: [str('agent', 'Agent to assign: a team member id, or a pending invite id from `team members`'), BODY],
56
+ overlay: (f) => {
57
+ if (f.agent !== undefined && !GUID.test(f.agent)) throw usage(`agent id is not a GUID: ${f.agent}`);
58
+ return compact({ agentId: f.agent });
59
+ },
60
+ }),
54
61
  cmd('properties update', 'Replace a property (wholesale)', 'PUT', '/openapi/Properties/{id}', {
55
62
  args: [id('id', 'Property id')], body: 'required', flags: [BODY, IF_MATCH],
56
63
  }),
@@ -95,7 +102,7 @@ const COMMANDS = [
95
102
  cmd('comping status', 'Get AI comping status for a transaction', 'GET', '/openapi/Transactions/{transactionId}/comping', {
96
103
  args: [id('transactionId', 'Transaction id')],
97
104
  }),
98
- cmd('team members', 'List team members', 'GET', '/openapi/Team/members', {
105
+ cmd('team members', 'List team members, including pending invites', 'GET', '/openapi/Team/members', {
99
106
  flags: [str('search', 'Free-text search', { query: 'search' }), ...PAGING],
100
107
  }),
101
108
  cmd('team get', 'Get one team member', 'GET', '/openapi/Team/members/{id}', { args: [id('id', 'Member id')] }),
@@ -137,6 +144,14 @@ const COMMANDS = [
137
144
  }),
138
145
  cmd('login', 'Sign in through the browser and store a CLI key', null, null, {
139
146
  auth: false,
147
+ notes: [
148
+ 'Opens the Zoodealio sign-in page in your browser. Sign in the way you usually do and click',
149
+ 'Authorize; this terminal then finishes by itself. In a terminal, press Enter to open it',
150
+ 'again, c to copy the link, or q to cancel. The link is always printed too, for when no browser',
151
+ 'can be opened (SSH, containers: use --no-browser and open it on another device).',
152
+ 'The key is saved for this computer; zoodealio logout removes it.',
153
+ 'On CI or a server, set ZOODEALIO_API_KEY instead.',
154
+ ],
140
155
  flags: [
141
156
  { name: 'no-browser', type: 'boolean', description: 'Print the sign-in URL to stderr instead of opening a browser' },
142
157
  str('name', 'Device name shown at sign-in (default: this host)'),
@@ -149,7 +164,15 @@ const COMMANDS = [
149
164
  build: (f) => ({ name: f.name }),
150
165
  }),
151
166
  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'),
167
+ cmd('mcp', 'Run a stdio MCP server bridged to /openapi/mcp', 'POST', '/openapi/mcp', {
168
+ notes: [
169
+ 'Uses your zoodealio login (or ZOODEALIO_API_KEY). Add it to an AI client:',
170
+ ' Claude Code: claude mcp add --scope user zoodealio -- zoodealio mcp',
171
+ ' Claude Desktop,',
172
+ ' Cursor, VS Code: {"command": "zoodealio", "args": ["mcp"]}',
173
+ 'Add --env dev to the args for the dev environment.',
174
+ ],
175
+ }),
153
176
  cmd('whoami', 'Check the API key and show the target origin', 'GET', '/openapi/Webhooks/event-types'),
154
177
  cmd('openapi', 'Print the partner OpenAPI document (no key needed)', 'GET', '/_openapi/partner.json', { auth: false }),
155
178
  cmd('api', 'Call any /openapi endpoint directly (DELETE needs --yes)', null, null, {
@@ -163,7 +186,7 @@ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
163
186
 
164
187
  /** Flags accepted by every command. */
165
188
  const GLOBAL_FLAGS = /** @type {Flag[]} */ ([
166
- str('env', 'prod (default) or dev; ZOODEALIO_API_URL overrides'),
189
+ str('env', 'prod or dev (default prod; dev on a dev build); ZOODEALIO_API_URL overrides'),
167
190
  { name: 'pretty', type: 'boolean', description: 'Pretty-print JSON even when piped' },
168
191
  { name: 'dry-run', type: 'boolean', description: 'Print the request (key redacted) without sending it' },
169
192
  { name: 'yes', type: 'boolean', description: 'Confirm a destructive command (not needed with --dry-run)' },
@@ -208,8 +231,21 @@ function helpText(prefix) {
208
231
  if (exact.destructive) lines.push('Destructive: requires --yes.');
209
232
  for (const a of exact.args) lines.push(` <${a.name}> ${a.description}`);
210
233
  for (const f of exact.flags) lines.push(` --${f.name} ${f.description}${f.required ? ' (required)' : ''}`);
234
+ if (exact.notes) lines.push('', ...exact.notes);
211
235
  } else {
212
236
  lines.push('zoodealio <command> [args] [flags]', '', 'JSON on stdout; errors as JSON on stderr. Sign in with `zoodealio login` or set ZOODEALIO_API_KEY.', '');
237
+ if (prefix.length === 0) {
238
+ lines.push(
239
+ 'Get started:',
240
+ ' zoodealio login Sign in through your browser',
241
+ ' zoodealio whoami Check who you\'re signed in as',
242
+ ' zoodealio properties list Your first call',
243
+ ' zoodealio mcp --help Connect an AI client (Claude, Cursor, VS Code)',
244
+ ' zoodealio commands Every command as JSON, for AI agents',
245
+ '',
246
+ 'Commands:',
247
+ );
248
+ }
213
249
  for (const c of matches) lines.push(` ${c.name.padEnd(28)}${c.summary}`);
214
250
  lines.push('', 'Global flags:');
215
251
  for (const f of GLOBAL_FLAGS) lines.push(` --${f.name.padEnd(10)}${f.description}`);
@@ -69,9 +69,11 @@ function writeAll(env, all) {
69
69
  * @param {Record<string, string | undefined>} env
70
70
  * @param {string} origin
71
71
  * @param {Credential} credential
72
+ * @returns {string} the credentials file
72
73
  */
73
74
  function saveCredential(env, origin, credential) {
74
75
  writeAll(env, { ...readAll(env, true), [origin]: credential });
76
+ return path.join(configDir(env), 'credentials.json');
75
77
  }
76
78
 
77
79
  /**
package/src/http.js CHANGED
@@ -4,6 +4,18 @@ const pkg = require('../package.json');
4
4
  const USER_AGENT = `zoodealio-cli/${pkg.version}`;
5
5
  const ENVS = { prod: 'https://api.zoodealio.ai', dev: 'https://dev.api.zoodealio.ai' };
6
6
 
7
+ /**
8
+ * CI publishes `<version>-dev.<run>` under the `dev` tag after the dev deployment, so that build
9
+ * talks to dev unless told otherwise. Every other version is a release and talks to prod.
10
+ * @param {string} version
11
+ * @returns {'prod'|'dev'}
12
+ */
13
+ function defaultEnv(version) {
14
+ return version.includes('-dev.') ? 'dev' : 'prod';
15
+ }
16
+
17
+ const DEFAULT_ENV = defaultEnv(pkg.version);
18
+
7
19
  /** @type {Record<number, [number, string]>} status -> [exit code, error code slug] */
8
20
  const STATUS_MAP = {
9
21
  400: [2, 'bad_request'],
@@ -47,7 +59,7 @@ function scrubKeys(text, keys) {
47
59
  function resolveOrigin(env, envName) {
48
60
  const override = env.ZOODEALIO_API_URL;
49
61
  if (override) return override.replace(/\/+$/, '');
50
- const name = envName || 'prod';
62
+ const name = envName || DEFAULT_ENV;
51
63
  if (!Object.hasOwn(ENVS, name)) throw usage('--env must be prod or dev');
52
64
  return ENVS[/** @type {'prod'|'dev'} */ (name)];
53
65
  }
@@ -123,4 +135,4 @@ async function send(doFetch, req) {
123
135
  }
124
136
  }
125
137
 
126
- module.exports = { ENVS, USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send };
138
+ module.exports = { ENVS, DEFAULT_ENV, USER_AGENT, defaultEnv, redactKey, scrubKeys, resolveOrigin, buildUrl, send };
package/src/login.js CHANGED
@@ -3,7 +3,7 @@ const http = require('node:http');
3
3
  const os = require('node:os');
4
4
  const { spawn } = require('node:child_process');
5
5
  const { CliError } = require('./errors');
6
- const { USER_AGENT, send } = require('./http');
6
+ const { DEFAULT_ENV, USER_AGENT, send } = require('./http');
7
7
 
8
8
  const WEB_ENVS = { prod: 'https://zoodealio.ai', dev: 'https://dev.zoodealio.ai' };
9
9
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -15,7 +15,7 @@ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
15
15
  */
16
16
  function resolveWebOrigin(env, envName) {
17
17
  if (env.ZOODEALIO_WEB_URL) return env.ZOODEALIO_WEB_URL.replace(/\/+$/, '');
18
- return WEB_ENVS[/** @type {'prod'|'dev'} */ (envName === 'dev' ? 'dev' : 'prod')];
18
+ return WEB_ENVS[/** @type {'prod'|'dev'} */ ((envName || DEFAULT_ENV) === 'dev' ? 'dev' : 'prod')];
19
19
  }
20
20
 
21
21
  /**
@@ -37,6 +37,27 @@ function openBrowser(url) {
37
37
  });
38
38
  }
39
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
+
40
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>`;
41
62
 
42
63
  /**
@@ -89,8 +110,13 @@ async function listenForCallback(state, timeoutMs) {
89
110
 
90
111
  /**
91
112
  * 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
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
94
120
  * @returns {Promise<{key: string, keyId: string, keyPrefix: string, name: string, email: string}>}
95
121
  */
96
122
  async function browserLogin(opts) {
@@ -100,31 +126,81 @@ async function browserLogin(opts) {
100
126
  const listener = await listenForCallback(state, opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
101
127
  try {
102
128
  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) {
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 () => {
105
133
  try {
106
134
  await (opts.openBrowser || openBrowser)(url);
107
- opened = true;
135
+ ui.say(` ${style.dim('Opened your browser. Nothing there? Use the link above, or press Enter.')}`);
108
136
  } catch {
109
- // fall through to printing the URL
137
+ ui.say(` ${style.red('Couldn\'t open a browser.')} Copy the link above into one.`);
110
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
+ });
111
174
  }
112
- if (!opened) opts.stderr.write(`Open this URL in a browser to sign in:\n${url}\n`);
175
+ ui.spin(`Waiting for you to authorize in the browser… ${style.dim(`(the link works for ${minutes} minute${minutes === 1 ? '' : 's'})`)}`);
113
176
 
114
- const { code, error } = await listener.result;
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;
115
185
  if (!code) {
116
186
  throw new CliError({
117
187
  exit: 1, code: error === 'cancelled' ? 'login_cancelled' : 'login_failed',
118
188
  title: error === 'cancelled' ? 'Login cancelled' : 'Login failed',
119
- detail: error === 'cancelled' ? 'Sign-in was cancelled in the browser.' : `The browser reported: ${error}`,
189
+ detail: error === 'cancelled' ? 'Sign-in was cancelled.' : `The browser reported: ${error}`,
120
190
  });
121
191
  }
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
- }));
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
+ }
128
204
  } finally {
129
205
  listener.close();
130
206
  }
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 };