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 +17 -48
- package/bin/zoodealio.js +23 -0
- package/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +37 -6
- package/src/commands.js +41 -5
- package/src/credentials.js +2 -0
- package/src/http.js +14 -2
- package/src/login.js +93 -17
- package/src/ui.js +59 -0
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
|
|
6
|
-
|
|
5
|
+
same contract as the Zoodealio MCP server and the Zoodealio Zapier app. It adds no API
|
|
6
|
+
surface: every command is one
|
|
7
7
|
documented `/openapi` request, and bodies pass through unchanged.
|
|
8
8
|
|
|
9
9
|
It has no runtime dependencies and needs Node 22 or newer.
|
|
@@ -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).
|
|
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`
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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.
|
|
229
|
-
[
|
|
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
package/src/args.js
CHANGED
|
@@ -29,7 +29,7 @@ function parse(argv) {
|
|
|
29
29
|
}
|
|
30
30
|
const { values, positionals } = parsed;
|
|
31
31
|
if (values.help) return { help: helpText(positionals) };
|
|
32
|
-
if (positionals.length === 0)
|
|
32
|
+
if (positionals.length === 0) return { help: helpText([]) };
|
|
33
33
|
|
|
34
34
|
let command;
|
|
35
35
|
for (const c of COMMANDS) {
|
package/src/cli.js
CHANGED
|
@@ -2,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
|
|
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
|
|
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,
|
|
69
|
-
noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser,
|
|
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', {
|
|
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
|
|
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}`);
|
package/src/credentials.js
CHANGED
|
@@ -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 ||
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
135
|
+
ui.say(` ${style.dim('Opened your browser. Nothing there? Use the link above, or press Enter.')}`);
|
|
108
136
|
} catch {
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
189
|
+
detail: error === 'cancelled' ? 'Sign-in was cancelled.' : `The browser reported: ${error}`,
|
|
120
190
|
});
|
|
121
191
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 };
|