@zocomputer/cli 0.2.1 → 0.3.0

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,6 +6,21 @@ see [runtime requirements](../README.md#set-up).
6
6
 
7
7
  ## Install or upgrade
8
8
 
9
+ The standalone installer is implemented but awaits its first public release.
10
+ Once published, macOS 15+ and Linux glibc 2.35+ users on arm64 or x64 can run:
11
+
12
+ ```sh
13
+ curl -fsSL https://zo.computer/install.sh | sh
14
+ ```
15
+
16
+ Then open a new terminal and run `zo --version`. It includes Node, verifies the archive, and keeps versions under
17
+ `${XDG_DATA_HOME:-$HOME/.local/share}/zo/cli/` with a managed `~/.local/bin/zo`
18
+ symlink, and adds `~/.local/bin` to your shell's PATH configuration unless it's
19
+ already on PATH. Re-run it to upgrade; `--version`, `--bin-dir`, and `--no-modify-path`
20
+ control pinned installs, command placement and shell setup. Existing sign-ins
21
+ are preserved. The [CLI guide](../../src/hostagent/system_api/docs/guides/cli.md)
22
+ covers supported systems, PATH collisions, rollback and removal.
23
+
9
24
  Install the latest published version from npm:
10
25
 
11
26
  ```sh
@@ -81,17 +96,27 @@ workspaces, a terminal prompts for a selection; scripts must supply
81
96
  person separately from the effective subject in the selected workspace.
82
97
  `context list`, `context use` and status refresh that view from the server.
83
98
 
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.
99
+ `login` signs in as the client ID
100
+ `https://www.zo.computer/oauth/zo-cli-client-metadata.json`, the CLI's OAuth
101
+ client metadata document, so the consent page names it "Zo CLI". By default it
102
+ uses an authorization code with PKCE (S256) and a loopback callback (RFC 8252):
103
+ it listens on `127.0.0.1` at a port the OS assigns, opens your browser at the
104
+ authorization URL, and prints that URL on stderr in case the browser doesn't
105
+ open. The browser returns to `http://127.0.0.1:<port>/callback`; the CLI accepts
106
+ the one callback whose `state` and `iss` match, redeems the code with its
107
+ verifier, and stops listening. It waits 10 minutes; Ctrl-C cancels (exit 130).
108
+ Denial exits 4 with `access_denied`; a timeout exits 3 with `login_timeout`.
109
+
110
+ `login --device` uses the device authorization grant (RFC 8628) instead: it
111
+ prints a URL and a code and polls the token endpoint until you approve on any
112
+ device. Approve only if the page shows the same code. Login does the same
113
+ without `--device` over SSH (`SSH_CONNECTION` or `SSH_TTY` set), with no
114
+ `DISPLAY` or `WAYLAND_DISPLAY` outside macOS, or when the browser fails to open.
115
+ The device request expires after at most 10 minutes, with the same exit codes.
116
+
117
+ A credential saved by an earlier release, which signed in as `zo-public-cli`,
118
+ is refused as `invalid_credential` after an upgrade, `login` included. Run
119
+ `zo logout` for that profile, then `zo login`.
95
120
 
96
121
  After approval, login lists every approved workspace with the identity the CLI
97
122
  acts as there (`as you` or `as service account <id>`), even when there is only
@@ -110,15 +135,16 @@ token. `ZO_SYSTEM_API_ACCESS_TOKEN` supplies an explicit automation token for
110
135
  the selected profile's fixed origins; the CLI never saves or refreshes it,
111
136
  including when the server refuses it.
112
137
 
113
- `logout` revokes the saved sign-in and pending replaced sign-ins. If revocation
114
- fails, local credentials remain so you can retry. `logout --local-only`
115
- removes local credentials without revoking server access. Unset a bearer
116
- override before logging in or out of a saved profile.
138
+ `logout` revokes the saved sign-in and pending replaced sign-ins, then removes
139
+ them from this computer. If a revocation fails, for a network error, an HTTP
140
+ error or a credential it can't read (such as one saved by an earlier release),
141
+ logout prints a one-line warning with the reason, removes the local credential
142
+ anyway and exits 0; revoke the sign-in in **Authorized apps** if it may still be
143
+ in use. Ctrl-C during logout keeps what is left so a later logout can finish.
144
+ Unset a bearer override before logging in or out of a saved profile.
117
145
 
118
- If a credential file is malformed, revoke its login in **Authorized apps**,
119
- then run `logout --local-only` for that profile before signing in again.
120
- Ordinary login and logout preserve the unreadable file so they do not silently
121
- discard pending revocations.
146
+ Login refuses a malformed credential file, so it never silently discards pending
147
+ revocations; run `logout` for that profile first.
122
148
 
123
149
  ## Local storage
124
150
 
package/dist/argv.js CHANGED
@@ -18,8 +18,7 @@ export function parseGlobals(argv) {
18
18
  const name = arg.slice(2, equal === -1 ? undefined : equal);
19
19
  if (!/^[a-z][a-z0-9-]*$/.test(name))
20
20
  throw new CliError("Invalid option syntax.");
21
- const localSwitch = (name === "no-browser" && args[0] === "login") ||
22
- (name === "local-only" && args[0] === "logout") ||
21
+ const localSwitch = (name === "device" && args[0] === "login") ||
23
22
  (name === "headers" && args[0] === "auth" && args[1] === "token");
24
23
  const flag = switches.has(name) || localSwitch || (name === "version" && !command);
25
24
  if (flag && equal !== -1)
package/dist/auth.d.ts CHANGED
@@ -35,9 +35,22 @@ export declare class AuthClient {
35
35
  private cleanup;
36
36
  /** RFC 8628 section 3.4 polling until approval, denial or expiry. */
37
37
  private poll;
38
+ /**
39
+ * Authorization code with PKCE over a loopback callback when this machine
40
+ * can open a browser; the device flow with --device, without a local
41
+ * browser, or when opening the browser fails.
42
+ */
43
+ private authorize;
38
44
  login(options?: LoginOptions): Promise<{
39
45
  cleanupPending: boolean;
40
46
  }>;
41
- logout(localOnly?: boolean): Promise<void>;
47
+ /**
48
+ * Revoke the saved sign-in and pending replaced sign-ins on the server, then remove them from
49
+ * this computer. A revocation that fails, or a credential that can't be read, still removes the
50
+ * local file and returns the reason as a warning; only an interruption keeps it.
51
+ */
52
+ logout(): Promise<{
53
+ warning?: string;
54
+ }>;
42
55
  }
43
56
  export {};
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 { browserAvailable, DEVICE_CODE_GRANT, deviceAuthorization, openBrowser, pause, } from "./login.js";
4
+ import { browserAvailable, DEVICE_CODE_GRANT, deviceAuthorization, loopbackAuthorization, 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
+ // How long login waits for the browser opener to fail before printing the
9
+ // browser URL and waiting on the callback alone.
10
+ const OPENER_GRACE_MS = 1_000;
8
11
  // Longer than requestJson's 8-second default, and short enough that a retry
9
12
  // after a lost approval lands inside the issuer's 30-second redemption window.
10
13
  const DEVICE_POLL_TIMEOUT_MS = 20_000;
@@ -28,8 +31,24 @@ function tokens(raw, now) {
28
31
  expiresAt: now + raw.expires_in * 1000,
29
32
  };
30
33
  }
34
+ /** A revocation the issuer did not confirm, with why: a network error or the HTTP status. */
35
+ class RevocationError extends CliError {
36
+ detail;
37
+ constructor(detail) {
38
+ super(`Revocation failed (${detail}). Local credentials were retained; retry zo logout.`, 5, "revocation_failed");
39
+ this.detail = detail;
40
+ }
41
+ }
42
+ /** Why logout could not revoke, in one line. */
43
+ function revocationProblem(error) {
44
+ if (error instanceof RevocationError)
45
+ return `the server did not confirm it (${error.detail})`;
46
+ if (error instanceof CliError && error.code === "invalid_credential")
47
+ return "the saved credential can't be read, for example one saved by an earlier zo version";
48
+ return error instanceof CliError ? error.code : "unexpected error";
49
+ }
31
50
  function invalidCredential() {
32
- return new CliError("The saved credential cannot be read safely. Revoke Zo CLI in Authorized apps, then run zo logout --local-only for this profile before signing in again. Local-only logout does not revoke server access.", 3, "invalid_credential");
51
+ return new CliError("The saved credential cannot be read safely. Run zo logout for this profile, then zo login. If logout can't revoke it, revoke Zo CLI in Authorized apps.", 3, "invalid_credential");
33
52
  }
34
53
  export class AuthClient {
35
54
  profile;
@@ -202,12 +221,12 @@ export class AuthClient {
202
221
  }
203
222
  catch {
204
223
  throwIfInterrupted(signal);
205
- throw new CliError("Revocation failed. Local credentials were retained; retry zo logout.", 5, "revocation_failed");
224
+ throw new RevocationError("network error");
206
225
  }
207
226
  await response.body?.cancel();
208
227
  throwIfInterrupted(signal);
209
228
  if (!response.ok)
210
- throw new CliError("Revocation failed. Local credentials were retained; retry zo logout.", 5, "revocation_failed");
229
+ throw new RevocationError(`HTTP ${response.status}`);
211
230
  }
212
231
  async cleanup(credential, signal = this.signal) {
213
232
  while (credential.pendingRevocations.length) {
@@ -275,6 +294,50 @@ export class AuthClient {
275
294
  }
276
295
  }
277
296
  }
297
+ /**
298
+ * Authorization code with PKCE over a loopback callback when this machine
299
+ * can open a browser; the device flow with --device, without a local
300
+ * browser, or when opening the browser fails.
301
+ */
302
+ async authorize(options, signal) {
303
+ if (!options.device &&
304
+ browserAvailable(this.env, options.platform ?? process.platform)) {
305
+ const loopback = await loopbackAuthorization(this.binding.issuer, this.binding.audience, this.binding.clientId, { signal, timeoutMs: options.callbackTimeoutMs });
306
+ // The opener can outlive the sign-in (xdg-open may become the browser
307
+ // itself), so it is never awaited past a short grace: a failure inside
308
+ // it falls back to the device flow; after it, the printed URL stands.
309
+ // The callback, the timeout and Ctrl-C end the wait on their own.
310
+ let grace;
311
+ const outcome = await Promise.race([
312
+ (options.openBrowser ?? openBrowser)(loopback.url).then(() => "opened", () => "failed"),
313
+ new Promise((resolve) => {
314
+ grace = setTimeout(() => resolve("pending"), OPENER_GRACE_MS);
315
+ }),
316
+ loopback.code.then(() => "pending", () => "pending"),
317
+ ]);
318
+ clearTimeout(grace);
319
+ if (outcome !== "failed") {
320
+ options.announce?.({ flow: "browser", url: loopback.url });
321
+ const code = await loopback.code;
322
+ throwIfInterrupted(signal);
323
+ return this.tokenRequest({
324
+ grant_type: "authorization_code",
325
+ code,
326
+ redirect_uri: loopback.redirectUri,
327
+ code_verifier: loopback.codeVerifier,
328
+ }, signal);
329
+ }
330
+ await loopback.close();
331
+ throwIfInterrupted(signal);
332
+ }
333
+ const device = await deviceAuthorization(this.binding.issuer, this.binding.audience, this.binding.clientId, this.fetcher, signal);
334
+ options.announce?.({
335
+ flow: "device",
336
+ url: device.url,
337
+ userCode: device.userCode,
338
+ });
339
+ return this.poll(device, signal);
340
+ }
278
341
  async login(options = {}) {
279
342
  const signal = this.signal && options.signal
280
343
  ? AbortSignal.any([this.signal, options.signal])
@@ -285,18 +348,7 @@ export class AuthClient {
285
348
  return this.locked(async () => {
286
349
  const previous = await this.read();
287
350
  throwIfInterrupted(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);
351
+ const active = await this.authorize(options, signal);
300
352
  const credential = {
301
353
  version: 1,
302
354
  binding: this.binding,
@@ -335,28 +387,35 @@ export class AuthClient {
335
387
  return { cleanupPending: false };
336
388
  }, signal);
337
389
  }
338
- async logout(localOnly = false) {
390
+ /**
391
+ * Revoke the saved sign-in and pending replaced sign-ins on the server, then remove them from
392
+ * this computer. A revocation that fails, or a credential that can't be read, still removes the
393
+ * local file and returns the reason as a warning; only an interruption keeps it.
394
+ */
395
+ async logout() {
339
396
  throwIfInterrupted(this.signal);
340
397
  if (this.env.ZO_SYSTEM_API_ACCESS_TOKEN !== undefined)
341
398
  throw new CliError("Unset ZO_SYSTEM_API_ACCESS_TOKEN before logging out a saved profile.");
342
- await this.locked(async () => {
343
- if (localOnly) {
344
- await removePrivate(this.path);
399
+ return this.locked(async () => {
400
+ let warning;
401
+ try {
402
+ const credential = await this.read();
345
403
  throwIfInterrupted(this.signal);
346
- return;
404
+ if (credential?.active) {
405
+ await this.revoke(credential.active.refreshToken);
406
+ delete credential.active;
407
+ await writePrivate(this.path, credential);
408
+ }
409
+ if (credential)
410
+ await this.cleanup(credential);
347
411
  }
348
- const credential = await this.read();
349
- throwIfInterrupted(this.signal);
350
- if (!credential)
351
- return;
352
- if (credential.active) {
353
- await this.revoke(credential.active.refreshToken);
354
- delete credential.active;
355
- await writePrivate(this.path, credential);
412
+ catch (error) {
413
+ throwIfInterrupted(this.signal);
414
+ warning = `Could not revoke the saved sign-in on the server: ${revocationProblem(error)}. It was removed from this computer; revoke Zo CLI in Authorized apps if it may still be in use.`;
356
415
  }
357
- await this.cleanup(credential);
358
416
  await removePrivate(this.path);
359
417
  throwIfInterrupted(this.signal);
418
+ return warning === undefined ? {} : { warning };
360
419
  });
361
420
  }
362
421
  }