zoodealio 0.2.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 +13 -7
- package/bin/zoodealio.js +23 -0
- package/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +27 -3
- package/src/commands.js +31 -2
- package/src/credentials.js +2 -0
- package/src/login.js +91 -15
- 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.
|
|
@@ -31,9 +31,15 @@ zoodealio login --name work-laptop # device name shown at sign-in (default: th
|
|
|
31
31
|
zoodealio logout # revokes this machine's key and forgets it
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`login`
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
39
|
+
|
|
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.
|
|
37
43
|
|
|
38
44
|
The key is stored per API origin in `$XDG_CONFIG_HOME/zoodealio/credentials.json`
|
|
39
45
|
(default `~/.config/zoodealio/credentials.json`; `%APPDATA%\zoodealio\credentials.json` on Windows),
|
|
@@ -225,8 +231,8 @@ zoodealio api GET Properties --query pageSize=5
|
|
|
225
231
|
destructive action (accepting an offer, removing a member, deleting or rotating a
|
|
226
232
|
webhook); do not add it to make an error go away.
|
|
227
233
|
- 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
|
-
[
|
|
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.
|
|
230
236
|
|
|
231
237
|
## Limits
|
|
232
238
|
|
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
|
@@ -6,11 +6,13 @@ const { USER_AGENT, redactKey, scrubKeys, resolveOrigin, buildUrl, send } = requ
|
|
|
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
|
/**
|
|
@@ -64,12 +66,34 @@ async function execute(argv, io, envKey, secrets) {
|
|
|
64
66
|
if (['login', 'logout', 'mcp'].includes(command.name)) {
|
|
65
67
|
if (flags['dry-run']) throw usage(`--dry-run is not supported for \`${command.name}\``);
|
|
66
68
|
if (command.name === 'login') {
|
|
69
|
+
const ui = createUi(io.stderr, { fancy: !!io.interactive && !io.env.NO_COLOR });
|
|
67
70
|
const result = await browserLogin({
|
|
68
71
|
apiOrigin: origin, webOrigin: resolveWebOrigin(io.env, flags.env), device: (flags.name?.trim() || defaultDevice()).slice(0, 64),
|
|
69
|
-
noBrowser: !!flags['no-browser'], openBrowser: io.openBrowser,
|
|
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,
|
|
70
74
|
});
|
|
71
75
|
secrets.add(result.key);
|
|
72
|
-
saveCredential(io.env, origin, { key: result.key, keyId: result.keyId, email: result.email, name: result.name });
|
|
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;
|
|
73
97
|
return emit({ ok: true, email: result.email, name: result.name, keyPrefix: result.keyPrefix, origin });
|
|
74
98
|
}
|
|
75
99
|
if (command.name === 'logout') {
|
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
|
/**
|
|
@@ -137,6 +137,14 @@ const COMMANDS = [
|
|
|
137
137
|
}),
|
|
138
138
|
cmd('login', 'Sign in through the browser and store a CLI key', null, null, {
|
|
139
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
|
+
],
|
|
140
148
|
flags: [
|
|
141
149
|
{ name: 'no-browser', type: 'boolean', description: 'Print the sign-in URL to stderr instead of opening a browser' },
|
|
142
150
|
str('name', 'Device name shown at sign-in (default: this host)'),
|
|
@@ -149,7 +157,15 @@ const COMMANDS = [
|
|
|
149
157
|
build: (f) => ({ name: f.name }),
|
|
150
158
|
}),
|
|
151
159
|
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'
|
|
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
|
+
}),
|
|
153
169
|
cmd('whoami', 'Check the API key and show the target origin', 'GET', '/openapi/Webhooks/event-types'),
|
|
154
170
|
cmd('openapi', 'Print the partner OpenAPI document (no key needed)', 'GET', '/_openapi/partner.json', { auth: false }),
|
|
155
171
|
cmd('api', 'Call any /openapi endpoint directly (DELETE needs --yes)', null, null, {
|
|
@@ -208,8 +224,21 @@ function helpText(prefix) {
|
|
|
208
224
|
if (exact.destructive) lines.push('Destructive: requires --yes.');
|
|
209
225
|
for (const a of exact.args) lines.push(` <${a.name}> ${a.description}`);
|
|
210
226
|
for (const f of exact.flags) lines.push(` --${f.name} ${f.description}${f.required ? ' (required)' : ''}`);
|
|
227
|
+
if (exact.notes) lines.push('', ...exact.notes);
|
|
211
228
|
} else {
|
|
212
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
|
+
}
|
|
213
242
|
for (const c of matches) lines.push(` ${c.name.padEnd(28)}${c.summary}`);
|
|
214
243
|
lines.push('', 'Global flags:');
|
|
215
244
|
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/login.js
CHANGED
|
@@ -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 };
|