@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
platform?: NodeJS.Platform;
|
|
12
|
+
announce?: (prompt: LoginPrompt) => void;
|
|
6
13
|
openBrowser?: (url: string) => Promise<void>;
|
|
7
14
|
}
|
|
8
|
-
export interface
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
17
|
+
"@zocomputer/system-api": "0.1.3"
|
|
18
18
|
},
|
|
19
19
|
"publishConfig": {
|
|
20
20
|
"access": "public",
|