@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/dist/http.d.ts CHANGED
@@ -3,7 +3,11 @@ import { CliError } from "./errors.ts";
3
3
  export declare function systemApiFailure(response: Response, raw: string | Uint8Array, operation: string): SystemAPIError;
4
4
  export declare class RequestFailure extends CliError {
5
5
  readonly ambiguous: boolean;
6
- constructor(ambiguous: boolean, code?: string, exitCode?: number);
6
+ /** The HTTP status when the server answered; absent for transport failures. */
7
+ readonly status?: number | undefined;
8
+ constructor(ambiguous: boolean, code?: string, exitCode?: number,
9
+ /** The HTTP status when the server answered; absent for transport failures. */
10
+ status?: number | undefined);
7
11
  }
8
12
  export declare function requestJson(url: string, init?: RequestInit, fetcher?: typeof fetch, options?: {
9
13
  timeoutMs?: number;
package/dist/http.js CHANGED
@@ -46,11 +46,15 @@ export function systemApiFailure(response, raw, operation) {
46
46
  }
47
47
  export class RequestFailure extends CliError {
48
48
  ambiguous;
49
- constructor(ambiguous, code = "transport_error", exitCode = 5) {
49
+ status;
50
+ constructor(ambiguous, code = "transport_error", exitCode = 5,
51
+ /** The HTTP status when the server answered; absent for transport failures. */
52
+ status) {
50
53
  super(code === "invalid_grant"
51
54
  ? "Your sign-in ended. Run zo login."
52
55
  : "The authentication request failed. Try again or run zo login.", exitCode, code);
53
56
  this.ambiguous = ambiguous;
57
+ this.status = status;
54
58
  }
55
59
  }
56
60
  export async function requestJson(url, init = {}, fetcher = fetch, options = {}) {
@@ -106,6 +110,10 @@ export async function requestJson(url, init = {}, fetcher = fetch, options = {})
106
110
  body = JSON.parse(raw);
107
111
  }
108
112
  catch {
113
+ // Cloudflare's edge rate limits answer 429 with a non-JSON page before the
114
+ // worker runs; the status alone says the request was not processed.
115
+ if (response.status === 429)
116
+ throw new RequestFailure(true, "temporarily_unavailable", 5, 429);
109
117
  throw new RequestFailure(false, "invalid_response");
110
118
  }
111
119
  if (!response.ok) {
@@ -118,7 +126,7 @@ export async function requestJson(url, init = {}, fetcher = fetch, options = {})
118
126
  ? 3
119
127
  : response.status === 403
120
128
  ? 4
121
- : 5);
129
+ : 5, response.status);
122
130
  }
123
131
  return body;
124
132
  }
package/dist/login.d.ts CHANGED
@@ -1,14 +1,30 @@
1
+ export declare const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
2
+ export interface LoginPrompt {
3
+ url: string;
4
+ userCode: string;
5
+ /** False when the CLI won't open a browser: --no-browser, SSH or no display. */
6
+ browser: boolean;
7
+ }
1
8
  export interface LoginOptions {
2
9
  noBrowser?: boolean;
3
- timeoutMs?: number;
4
10
  signal?: AbortSignal;
5
- announce?: (url: string) => void;
11
+ platform?: NodeJS.Platform;
12
+ announce?: (prompt: LoginPrompt) => void;
6
13
  openBrowser?: (url: string) => Promise<void>;
7
14
  }
8
- export interface LoginCode {
9
- code: string;
10
- verifier: string;
11
- redirectUri: string;
15
+ export interface DeviceAuthorization {
16
+ deviceCode: string;
17
+ userCode: string;
18
+ url: string;
19
+ expiresInMs: number;
20
+ intervalMs: number;
12
21
  }
13
22
  export declare function openBrowser(url: string): Promise<void>;
14
- export declare function loginCode(issuer: string, audience: string, clientId: string, options?: LoginOptions): Promise<LoginCode>;
23
+ /**
24
+ * A browser opened over SSH, or with no display, lands on the wrong machine or
25
+ * nowhere. xdg-open needs a display on every system other than macOS.
26
+ */
27
+ export declare function browserAvailable(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
28
+ export declare function pause(ms: number, signal?: AbortSignal | null): Promise<void>;
29
+ export declare function parseDeviceAuthorization(raw: unknown, issuer: string): DeviceAuthorization;
30
+ export declare function deviceAuthorization(issuer: string, audience: string, clientId: string, fetcher: typeof fetch, signal?: AbortSignal): Promise<DeviceAuthorization>;
package/dist/login.js CHANGED
@@ -1,102 +1,93 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { createServer } from "node:http";
3
1
  import { spawn } from "node:child_process";
4
- import { CliError } from "./errors.js";
2
+ import { CliError, record, throwIfInterrupted } from "./errors.js";
3
+ import { requestJson, RequestFailure } from "./http.js";
4
+ export const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
5
5
  export async function openBrowser(url) {
6
6
  await new Promise((resolve, reject) => {
7
7
  const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true });
8
8
  child.unref();
9
- child.once("error", () => reject(new CliError("Could not open a browser. Retry with --no-browser.")));
10
- child.once("exit", (status) => status === 0
11
- ? resolve()
12
- : reject(new CliError("Could not open a browser. Retry with --no-browser.")));
9
+ child.once("error", reject);
10
+ child.once("exit", (status) => status === 0 ? resolve() : reject(new Error("Browser opener failed.")));
13
11
  });
14
12
  }
15
- export async function loginCode(issuer, audience, clientId, options = {}) {
16
- const state = randomBytes(32).toString("base64url");
17
- const verifier = randomBytes(32).toString("base64url");
18
- let complete = () => { };
19
- let fail = () => { };
20
- const received = new Promise((resolve, reject) => {
21
- complete = resolve;
22
- fail = reject;
23
- });
24
- // The callback may arrive while the browser opener is still running.
25
- void received.catch(() => { });
26
- let accepted = false;
27
- const server = createServer((request, response) => {
28
- let url;
29
- try {
30
- url = new URL(request.url ?? "/", "http://127.0.0.1");
31
- }
32
- catch {
33
- response.writeHead(400).end("Invalid callback.");
34
- return;
35
- }
36
- const params = url.searchParams;
37
- const keys = [...params.keys()];
38
- const valid = !accepted &&
39
- request.method === "GET" &&
40
- url.pathname === "/callback" &&
41
- keys.length === new Set(keys).size &&
42
- params.get("state") === state &&
43
- params.get("iss") === issuer &&
44
- params.has("code") !== params.has("error");
45
- response.setHeader("Content-Type", "text/plain; charset=utf-8");
46
- response.setHeader("Cache-Control", "no-store");
47
- if (!valid) {
48
- response.writeHead(400).end("Invalid callback.");
49
- return;
50
- }
51
- accepted = true;
52
- response.end("You may close this window and return to Zo.");
53
- if (params.has("error"))
54
- fail(new CliError("Authorization was denied. Run zo login to try again.", 4, "access_denied"));
55
- else {
56
- const code = params.get("code");
57
- if (code)
58
- complete(code);
59
- else
60
- fail(new CliError("Authorization returned an empty code.", 3, "invalid_response"));
61
- }
62
- });
63
- server.requestTimeout = 5_000;
64
- server.headersTimeout = 5_000;
65
- await new Promise((resolve, reject) => {
66
- server.once("error", reject);
67
- server.listen(0, "127.0.0.1", resolve);
13
+ /**
14
+ * A browser opened over SSH, or with no display, lands on the wrong machine or
15
+ * nowhere. xdg-open needs a display on every system other than macOS.
16
+ */
17
+ export function browserAvailable(env, platform = process.platform) {
18
+ if (env.SSH_CONNECTION || env.SSH_TTY)
19
+ return false;
20
+ return platform === "darwin" || !!env.DISPLAY || !!env.WAYLAND_DISPLAY;
21
+ }
22
+ export function pause(ms, signal) {
23
+ throwIfInterrupted(signal);
24
+ return new Promise((resolve, reject) => {
25
+ const abort = () => {
26
+ clearTimeout(timer);
27
+ reject(new CliError("Interrupted.", 130, "interrupted"));
28
+ };
29
+ const timer = setTimeout(() => {
30
+ signal?.removeEventListener("abort", abort);
31
+ resolve();
32
+ }, ms);
33
+ signal?.addEventListener("abort", abort, { once: true });
68
34
  });
69
- const address = server.address();
70
- if (!address || typeof address === "string")
71
- throw new CliError("Could not bind the login callback.");
72
- const redirectUri = `http://127.0.0.1:${address.port}/callback`;
73
- const authorize = new URL("/zo-system-api/oauth/authorize", issuer);
74
- authorize.search = new URLSearchParams({
75
- response_type: "code",
76
- client_id: clientId,
77
- redirect_uri: redirectUri,
78
- resource: audience,
79
- code_challenge: createHash("sha256").update(verifier).digest("base64url"),
80
- code_challenge_method: "S256",
81
- state,
82
- }).toString();
83
- const timer = setTimeout(() => fail(new CliError("Login timed out. Run zo login to try again.", 3, "login_timeout")), options.timeoutMs ?? 300_000);
84
- const abort = () => fail(new CliError("Login interrupted.", 130, "interrupted"));
85
- options.signal?.addEventListener("abort", abort, { once: true });
35
+ }
36
+ function seconds(value, maximum) {
37
+ return typeof value === "number" &&
38
+ Number.isSafeInteger(value) &&
39
+ value > 0 &&
40
+ value <= maximum
41
+ ? value
42
+ : undefined;
43
+ }
44
+ export function parseDeviceAuthorization(raw, issuer) {
45
+ const invalid = () => new CliError("The sign-in service returned an invalid response.", 5, "invalid_response");
46
+ if (!record(raw) ||
47
+ typeof raw.device_code !== "string" ||
48
+ !/^[A-Za-z0-9._~-]{1,512}$/.test(raw.device_code) ||
49
+ typeof raw.user_code !== "string" ||
50
+ !/^[A-Z0-9]{1,8}(?:-[A-Z0-9]{1,8}){0,3}$/.test(raw.user_code) ||
51
+ typeof raw.verification_uri_complete !== "string")
52
+ throw invalid();
53
+ const expiresIn = seconds(raw.expires_in, 3_600);
54
+ const interval = raw.interval === undefined ? 2 : seconds(raw.interval, 60);
55
+ if (expiresIn === undefined || interval === undefined)
56
+ throw invalid();
57
+ let url;
58
+ try {
59
+ url = new URL(raw.verification_uri_complete);
60
+ }
61
+ catch {
62
+ throw invalid();
63
+ }
64
+ // The URL goes to the system opener; only the issuer's own scheme is safe.
65
+ // Production is https; the local test issuer is http.
66
+ if (url.protocol !== new URL(issuer).protocol || url.username || url.password)
67
+ throw invalid();
68
+ return {
69
+ deviceCode: raw.device_code,
70
+ userCode: raw.user_code,
71
+ url: url.href,
72
+ expiresInMs: expiresIn * 1000,
73
+ intervalMs: interval * 1000,
74
+ };
75
+ }
76
+ export async function deviceAuthorization(issuer, audience, clientId, fetcher, signal) {
77
+ let raw;
86
78
  try {
87
- if (options.signal?.aborted)
88
- abort();
89
- else {
90
- options.announce?.(authorize.href);
91
- if (!options.noBrowser)
92
- void (options.openBrowser ?? openBrowser)(authorize.href).catch(fail);
93
- }
94
- return { code: await received, verifier, redirectUri };
79
+ raw = await requestJson(`${issuer}/zo-system-api/oauth/device_authorization`, {
80
+ method: "POST",
81
+ signal,
82
+ body: new URLSearchParams({ client_id: clientId, resource: audience }),
83
+ }, fetcher);
95
84
  }
96
- finally {
97
- clearTimeout(timer);
98
- options.signal?.removeEventListener("abort", abort);
99
- server.closeAllConnections();
100
- await new Promise((resolve) => server.close(() => resolve()));
85
+ catch (error) {
86
+ if (error instanceof RequestFailure && error.status === 429)
87
+ throw new CliError("Too many sign-in attempts from this network. Wait a minute, then run zo login again.", 5, "rate_limited");
88
+ if (error instanceof RequestFailure && error.code === "unauthorized_client")
89
+ throw new CliError("This environment does not allow Zo CLI sign-in yet.", 5, "unauthorized_client");
90
+ throw error;
101
91
  }
92
+ return parseDeviceAuthorization(raw, issuer);
102
93
  }
package/dist/main.js CHANGED
@@ -26,7 +26,7 @@ Use --profile to keep separate sign-ins. New profiles use production.
26
26
  ZO_SYSTEM_API_ACCESS_TOKEN overrides saved credentials; ZO_WORKSPACE_ID selects a workspace.
27
27
  `;
28
28
  const localHelp = {
29
- login: "Usage: zo login [--profile <name>] [--workspace <id>] [--no-browser]\nPrint the sign-in URL, open your browser, and wait for approval.\nNew profiles use production. Browser consent approves workspaces and identities.\n--no-browser skips opening the browser and still waits for the localhost callback.\nInternal testing: --environment staging explicitly binds a separate profile to staging.\n",
29
+ login: "Usage: zo login [--profile <name>] [--workspace <id>] [--no-browser]\nPrint a sign-in URL and code, open your browser, and wait for approval.\nNew profiles use production. Browser consent approves workspaces and identities.\n--no-browser prints the URL without opening a browser; approve it on any device.\nInternal testing: --environment staging explicitly binds a separate profile to staging.\n",
30
30
  logout: "Usage: zo logout [--profile <name>] [--local-only]\nRevoke the saved sign-in and pending replaced sign-ins. --local-only removes local credentials without revoking server access.\n",
31
31
  auth: "Usage: zo auth status | token [--headers]\nStatus shows the current identity; token explicitly prints an access token.\n",
32
32
  "auth status": "Usage: zo auth status [--profile <name>] [--workspace <id>] [--json]\nShow the approving person, sign-in expiry and effective identity in the selected workspace.\n",
@@ -41,6 +41,16 @@ const localHelp = {
41
41
  rpc: "Usage: zo rpc <action> [--input <json> | --input-file <path|->] [--version <integer>]\n --query <json> Declared list-query options; one page per call\n --idempotency-key <key> Envelope key where the action supports it\n --preview Print the request without invoking\n --envelope Print the complete success envelope\n --json Print exact result JSON\n --profile <name> --workspace <id>\nThe live catalog supplies the default version. RPC preserves supplied inputs. Invocations are never retried.\n",
42
42
  completion: "Usage: zo completion bash|zsh|fish\nPrint completion from installed metadata without credentials or network requests.\n",
43
43
  };
44
+ function identity(session, workspace) {
45
+ const { type, id } = workspace.subject;
46
+ if (type === "user" &&
47
+ session.approving_subject?.type === "user" &&
48
+ session.approving_subject.id === id)
49
+ return "as you";
50
+ if (type === "service_account")
51
+ return `as service account ${safeText(id)}`;
52
+ return `as ${type} ${safeText(id)}`;
53
+ }
44
54
  async function showHelp(args, stdout) {
45
55
  if (!args.length) {
46
56
  stdout.write(help);
@@ -121,19 +131,17 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
121
131
  const login = await auth.login({
122
132
  noBrowser: parsed.flags.has("no-browser"),
123
133
  signal,
124
- announce: (url) => {
125
- const message = `Open this URL to sign in:\n${url}\n`;
126
- const dim = stderr.isTTY &&
127
- !options.json &&
128
- env.NO_COLOR === undefined &&
129
- env.TERM !== "dumb";
130
- stderr.write(dim ? `\u001b[2m${message}\u001b[22m` : message);
131
- },
134
+ announce: ({ url, userCode, browser }) => stderr.write(`${browser
135
+ ? "Opening your browser to sign in. If it doesn't open, visit:"
136
+ : "To sign in, visit this URL on any device:"}\n\n ${url}\n\nCode: ${userCode}. Approve only if your browser shows the same code.\nWaiting for approval… (Ctrl-C to cancel)\n`),
132
137
  });
133
- stderr.write(`Sign-in saved for profile ${profile.name}.\n`);
138
+ stderr.write(`\nApproved in the browser. Sign-in saved for profile ${profile.name}.\n`);
134
139
  if (login.cleanupPending)
135
140
  stderr.write("A previous sign-in could not be revoked. Cleanup remains saved for the next login; the current sign-in is active. You can revoke the previous sign-in in Authorized apps.\n");
136
141
  const session = await auth.session();
142
+ // Always list the approved workspaces, so a sign-in someone else
143
+ // approved shows up as workspaces or identities you don't recognize.
144
+ session.workspaces.forEach((workspace, index) => stderr.write(`${index + 1}. ${safeText(workspace.name)} (${workspace.workspace_id}), ${identity(session, workspace)}\n`));
137
145
  const desired = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
138
146
  let chosen;
139
147
  if (options.workspace !== undefined)
@@ -152,7 +160,6 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
152
160
  process.stdin.isTTY &&
153
161
  stderr.isTTY &&
154
162
  session.workspaces.length > 0) {
155
- session.workspaces.forEach((workspace, index) => stderr.write(`${index + 1}. ${safeText(workspace.name)} (${workspace.workspace_id})\n`));
156
163
  const input = createInterface({
157
164
  input: process.stdin,
158
165
  output: process.stderr,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zocomputer/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Public Zo command line client",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "node": "^22.23.4 || ^24.22.1 || ^26.13.0"
15
15
  },
16
16
  "dependencies": {
17
- "@zocomputer/system-api": "0.1.1"
17
+ "@zocomputer/system-api": "0.1.3"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",