@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 CHANGED
@@ -6,25 +6,26 @@ see [runtime requirements](../README.md#set-up).
6
6
 
7
7
  ## Install or upgrade
8
8
 
9
- After publication, install the versioned package:
9
+ Install the latest published version from npm:
10
10
 
11
11
  ```sh
12
- npm install --global @zocomputer/cli@0.1.1
12
+ npm install --global @zocomputer/cli@latest
13
13
  zo --version
14
14
  zo login
15
15
  ```
16
16
 
17
- Use the same install command with the desired version to upgrade. The CLI
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 test unpublished artifacts, install both tarballs together:
21
+ To try the CLI without installing it globally:
22
22
 
23
23
  ```sh
24
- npm install --global ./zocomputer-system-api-0.1.1.tgz ./zocomputer-cli-0.1.1.tgz
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/zo` from the repository root. Run `type -a zo`
54
- to diagnose a PATH collision; the two clients store separate sign-ins.
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` prints the authorization URL on its own line on stderr, opens your
82
- browser, and waits for sign-in to finish. You can copy the URL into another
83
- browser window. The message is dim on interactive terminals and plain when
84
- redirected, with `--json`, or when `NO_COLOR` is set.
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
- `login --no-browser` prints the same URL and waits without opening a browser.
87
- It is not a device-code login. The browser must reach the CLI's localhost callback.
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. Saving the received successor clears the marker. A crash, failure to
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 { loginCode } from "./login.js";
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 code = await loginCode(this.binding.issuer, this.binding.audience, this.binding.clientId, { ...options, signal });
222
- const active = await this.tokenRequest({
223
- grant_type: "authorization_code",
224
- code: code.code,
225
- code_verifier: code.verifier,
226
- redirect_uri: code.redirectUri,
227
- }, signal);
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,