@zocomputer/cli 0.1.1 → 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 +33 -15
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +82 -10
- package/dist/generated/commands.js +168 -44
- package/dist/http.d.ts +5 -1
- package/dist/http.js +10 -2
- package/dist/login.d.ts +23 -7
- package/dist/login.js +81 -90
- package/dist/main.js +18 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,25 +6,26 @@ see [runtime requirements](../README.md#set-up).
|
|
|
6
6
|
|
|
7
7
|
## Install or upgrade
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Install the latest published version from npm:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install --global @zocomputer/cli@
|
|
12
|
+
npm install --global @zocomputer/cli@latest
|
|
13
13
|
zo --version
|
|
14
14
|
zo login
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Run the same install command again to upgrade. The CLI
|
|
18
18
|
installs its matching public SDK dependency. It performs no update check when
|
|
19
19
|
you run a command.
|
|
20
20
|
|
|
21
|
-
To
|
|
21
|
+
To try the CLI without installing it globally:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
|
|
25
|
-
zo --version
|
|
24
|
+
npx -y @zocomputer/cli@latest --help
|
|
26
25
|
```
|
|
27
26
|
|
|
27
|
+
Replace `--help` with a command such as `login` or `--version`.
|
|
28
|
+
|
|
28
29
|
## Develop locally
|
|
29
30
|
|
|
30
31
|
Install the independent client workspace, then build the CLI:
|
|
@@ -50,8 +51,10 @@ node dist/main.js auth status
|
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
Use `node dist/main.js` while working in this repository. The repository's
|
|
53
|
-
internal operator CLI is `./zocli/
|
|
54
|
-
|
|
54
|
+
internal operator CLI is `zd` (`./zocli/zd` from the repository root).
|
|
55
|
+
If an old internal `zo` symlink is still on PATH, run `./zocli/zd setup` from
|
|
56
|
+
your main checkout and refresh your shell's command cache. Run `type -a zd zo`
|
|
57
|
+
to check both commands; the two clients store separate sign-ins.
|
|
55
58
|
|
|
56
59
|
## Accounts and workspaces
|
|
57
60
|
|
|
@@ -78,13 +81,26 @@ workspaces, a terminal prompts for a selection; scripts must supply
|
|
|
78
81
|
person separately from the effective subject in the selected workspace.
|
|
79
82
|
`context list`, `context use` and status refresh that view from the server.
|
|
80
83
|
|
|
81
|
-
`login`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
`login` uses the OAuth device authorization grant (RFC 8628). It prints a
|
|
85
|
+
sign-in URL and a code on stderr, opens your browser, and polls the token
|
|
86
|
+
endpoint until you approve or deny. Approve only if the page shows the same
|
|
87
|
+
code. Nothing connects back to the CLI's machine, so the URL works from any
|
|
88
|
+
browser or device. The request expires after 10 minutes; Ctrl-C cancels it
|
|
89
|
+
(exit 130). Denial exits 4 with `access_denied`; expiry exits 3 with
|
|
90
|
+
`login_timeout`.
|
|
91
|
+
|
|
92
|
+
Over SSH (`SSH_CONNECTION` or `SSH_TTY` set), or with no `DISPLAY` or
|
|
93
|
+
`WAYLAND_DISPLAY` outside macOS, login doesn't open a browser and asks you to
|
|
94
|
+
visit the URL on any device. `login --no-browser` does the same anywhere.
|
|
95
|
+
|
|
96
|
+
After approval, login lists every approved workspace with the identity the CLI
|
|
97
|
+
acts as there (`as you` or `as service account <id>`), even when there is only
|
|
98
|
+
one. A workspace you don't recognize means someone else approved your sign-in
|
|
99
|
+
link; run `logout` to revoke it.
|
|
85
100
|
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
Each poll waits up to 20 seconds. After a timeout or network error the CLI
|
|
102
|
+
retries once at once, inside the issuer's 30-second redemption window, then
|
|
103
|
+
resumes polling every `interval` seconds; `slow_down` and a 429 add 5 seconds.
|
|
88
104
|
|
|
89
105
|
## Tokens and logout
|
|
90
106
|
|
|
@@ -131,7 +147,9 @@ Before sending, the CLI durably marks the outcome uncertain. If syncing that
|
|
|
131
147
|
marker fails after replacement, it attempts to restore the original ready
|
|
132
148
|
credential and reports the storage error; no exchange was sent. A lost response
|
|
133
149
|
may be retried once under the lock within the server's 30-second recovery
|
|
134
|
-
window.
|
|
150
|
+
window. A 429 comes only from the rate limiter, before any exchange, so the
|
|
151
|
+
CLI restores the ready state, does not retry, and asks you to try again
|
|
152
|
+
shortly; a 429 on that retry leaves the marker in place. Saving the received successor clears the marker. A crash, failure to
|
|
135
153
|
replace the credential file, or unresolved second request leaves the marker
|
|
136
154
|
in place and requires login;
|
|
137
155
|
later processes cannot unknowingly replay that refresh token.
|
package/dist/auth.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface AuthOptions {
|
|
|
12
12
|
environment?: Binding;
|
|
13
13
|
env?: NodeJS.ProcessEnv;
|
|
14
14
|
now?: () => number;
|
|
15
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
15
16
|
signal?: AbortSignal;
|
|
16
17
|
}
|
|
17
18
|
export declare class AuthClient {
|
|
@@ -22,6 +23,7 @@ export declare class AuthClient {
|
|
|
22
23
|
private readonly fetcher;
|
|
23
24
|
private readonly env;
|
|
24
25
|
private readonly now;
|
|
26
|
+
private readonly sleep;
|
|
25
27
|
private readonly signal?;
|
|
26
28
|
constructor(profile: Profile, directory: string, options?: AuthOptions);
|
|
27
29
|
private locked;
|
|
@@ -31,6 +33,8 @@ export declare class AuthClient {
|
|
|
31
33
|
session(): Promise<Session>;
|
|
32
34
|
private revoke;
|
|
33
35
|
private cleanup;
|
|
36
|
+
/** RFC 8628 section 3.4 polling until approval, denial or expiry. */
|
|
37
|
+
private poll;
|
|
34
38
|
login(options?: LoginOptions): Promise<{
|
|
35
39
|
cleanupPending: boolean;
|
|
36
40
|
}>;
|
package/dist/auth.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { CliError, record, throwIfInterrupted } from "./errors.js";
|
|
3
3
|
import { requestJson, RequestFailure } from "./http.js";
|
|
4
|
-
import {
|
|
4
|
+
import { browserAvailable, DEVICE_CODE_GRANT, deviceAuthorization, openBrowser, pause, } from "./login.js";
|
|
5
5
|
import { environments, profileName } from "./profiles.js";
|
|
6
6
|
import { parseSession } from "./session.js";
|
|
7
7
|
import { privateDirectory, PrivateWriteDurabilityError, readPrivate, removePrivate, withLock, writePrivate, } from "./storage.js";
|
|
8
|
+
// Longer than requestJson's 8-second default, and short enough that a retry
|
|
9
|
+
// after a lost approval lands inside the issuer's 30-second redemption window.
|
|
10
|
+
const DEVICE_POLL_TIMEOUT_MS = 20_000;
|
|
8
11
|
function tokens(raw, now) {
|
|
9
12
|
if (!record(raw) ||
|
|
10
13
|
raw.error !== undefined ||
|
|
@@ -36,6 +39,7 @@ export class AuthClient {
|
|
|
36
39
|
fetcher;
|
|
37
40
|
env;
|
|
38
41
|
now;
|
|
42
|
+
sleep;
|
|
39
43
|
signal;
|
|
40
44
|
constructor(profile, directory, options = {}) {
|
|
41
45
|
this.profile = profile;
|
|
@@ -45,6 +49,7 @@ export class AuthClient {
|
|
|
45
49
|
this.fetcher = options.fetch ?? fetch;
|
|
46
50
|
this.env = options.env ?? process.env;
|
|
47
51
|
this.now = options.now ?? Date.now;
|
|
52
|
+
this.sleep = options.sleep ?? pause;
|
|
48
53
|
this.signal = options.signal;
|
|
49
54
|
}
|
|
50
55
|
async locked(work, signal = this.signal) {
|
|
@@ -97,7 +102,7 @@ export class AuthClient {
|
|
|
97
102
|
refreshState: raw.refreshState,
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
|
-
async tokenRequest(form, signal = this.signal) {
|
|
105
|
+
async tokenRequest(form, signal = this.signal, timeoutMs) {
|
|
101
106
|
const issuedAt = this.now();
|
|
102
107
|
return tokens(await requestJson(`${this.binding.issuer}/zo-system-api/oauth/token`, {
|
|
103
108
|
method: "POST",
|
|
@@ -107,7 +112,7 @@ export class AuthClient {
|
|
|
107
112
|
client_id: this.binding.clientId,
|
|
108
113
|
resource: this.binding.audience,
|
|
109
114
|
}),
|
|
110
|
-
}, this.fetcher), issuedAt);
|
|
115
|
+
}, this.fetcher, timeoutMs === undefined ? {} : { timeoutMs }), issuedAt);
|
|
111
116
|
}
|
|
112
117
|
async accessToken() {
|
|
113
118
|
throwIfInterrupted(this.signal);
|
|
@@ -150,6 +155,13 @@ export class AuthClient {
|
|
|
150
155
|
}
|
|
151
156
|
catch (error) {
|
|
152
157
|
throwIfInterrupted(this.signal);
|
|
158
|
+
if (error instanceof RequestFailure && error.status === 429) {
|
|
159
|
+
// Only the rate limiter answers 429, before any exchange, so the
|
|
160
|
+
// refresh token is unused. Retrying now would hit the same limit.
|
|
161
|
+
credential.refreshState = "ready";
|
|
162
|
+
await writePrivate(this.path, credential);
|
|
163
|
+
throw new CliError("Too many sign-in requests right now. Try again shortly.", 5, "rate_limited");
|
|
164
|
+
}
|
|
153
165
|
// Two eight-second requests fit the issuer's thirty-second recovery
|
|
154
166
|
// window. Wall time also catches suspension of the monotonic clock.
|
|
155
167
|
const elapsed = this.now() - startedAt;
|
|
@@ -208,6 +220,61 @@ export class AuthClient {
|
|
|
208
220
|
throwIfInterrupted(signal);
|
|
209
221
|
}
|
|
210
222
|
}
|
|
223
|
+
/** RFC 8628 section 3.4 polling until approval, denial or expiry. */
|
|
224
|
+
async poll(device, signal) {
|
|
225
|
+
const deadline = this.now() + device.expiresInMs;
|
|
226
|
+
const form = {
|
|
227
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
228
|
+
device_code: device.deviceCode,
|
|
229
|
+
};
|
|
230
|
+
const expired = () => new CliError("Login timed out. Run zo login to try again.", 3, "login_timeout");
|
|
231
|
+
let interval = device.intervalMs;
|
|
232
|
+
let retryNow = false;
|
|
233
|
+
for (;;) {
|
|
234
|
+
if (!retryNow)
|
|
235
|
+
await this.sleep(interval, signal);
|
|
236
|
+
throwIfInterrupted(signal);
|
|
237
|
+
if (this.now() >= deadline)
|
|
238
|
+
throw expired();
|
|
239
|
+
const retry = retryNow;
|
|
240
|
+
retryNow = false;
|
|
241
|
+
try {
|
|
242
|
+
return await this.tokenRequest(form, signal, DEVICE_POLL_TIMEOUT_MS);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
throwIfInterrupted(signal);
|
|
246
|
+
if (!(error instanceof RequestFailure))
|
|
247
|
+
throw error;
|
|
248
|
+
if (error.status === 400) {
|
|
249
|
+
if (error.code === "authorization_pending")
|
|
250
|
+
continue;
|
|
251
|
+
if (error.code === "slow_down") {
|
|
252
|
+
interval += 5_000;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (error.code === "access_denied")
|
|
256
|
+
throw new CliError("Authorization was denied. Run zo login to try again.", 4, "access_denied");
|
|
257
|
+
if (error.code === "expired_token")
|
|
258
|
+
throw expired();
|
|
259
|
+
if (error.code === "invalid_grant")
|
|
260
|
+
throw new CliError("This sign-in request can no longer be completed. Run zo login again.", 3, "invalid_grant");
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
// The rate limiter answered before the issuer saw the poll.
|
|
264
|
+
if (error.status === 429) {
|
|
265
|
+
interval += 5_000;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// A timeout, network error or 5xx may have lost an approval the issuer
|
|
269
|
+
// already redeemed. One immediate retry gets the same refresh token.
|
|
270
|
+
if (error.ambiguous) {
|
|
271
|
+
retryNow = !retry;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
211
278
|
async login(options = {}) {
|
|
212
279
|
const signal = this.signal && options.signal
|
|
213
280
|
? AbortSignal.any([this.signal, options.signal])
|
|
@@ -218,13 +285,18 @@ export class AuthClient {
|
|
|
218
285
|
return this.locked(async () => {
|
|
219
286
|
const previous = await this.read();
|
|
220
287
|
throwIfInterrupted(signal);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
288
|
+
const device = await deviceAuthorization(this.binding.issuer, this.binding.audience, this.binding.clientId, this.fetcher, signal);
|
|
289
|
+
const browser = !options.noBrowser &&
|
|
290
|
+
browserAvailable(this.env, options.platform ?? process.platform);
|
|
291
|
+
options.announce?.({
|
|
292
|
+
url: device.url,
|
|
293
|
+
userCode: device.userCode,
|
|
294
|
+
browser,
|
|
295
|
+
});
|
|
296
|
+
// The printed URL is the fallback when the opener fails.
|
|
297
|
+
if (browser)
|
|
298
|
+
void (options.openBrowser ?? openBrowser)(device.url).catch(() => { });
|
|
299
|
+
const active = await this.poll(device, signal);
|
|
228
300
|
const credential = {
|
|
229
301
|
version: 1,
|
|
230
302
|
binding: this.binding,
|