@zocomputer/cli 0.0.0-stage → 0.1.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
@@ -1,3 +1,169 @@
1
- # Temporary Holding Version
1
+ # Zo CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The public Zo CLI signs in through your browser and selects a workspace for
4
+ System API commands. It supports the maintained Node 22, 24 and 26 LTS lines on macOS and Linux;
5
+ see [runtime requirements](../README.md#set-up).
6
+ Version 0.1.0 is a release candidate. Publish it only after the production
7
+ OAuth client and session endpoint are live and the release checks pass.
8
+
9
+ ## Install or upgrade
10
+
11
+ After publication, install the versioned package:
12
+
13
+ ```sh
14
+ npm install --global @zocomputer/cli@0.1.0
15
+ zo --version
16
+ zo login
17
+ ```
18
+
19
+ Use the same install command with the desired version to upgrade. The CLI
20
+ installs its matching public SDK dependency. It performs no update check when
21
+ you run a command.
22
+
23
+ To test unpublished artifacts, install both tarballs together:
24
+
25
+ ```sh
26
+ npm install --global ./zocomputer-system-api-0.1.0.tgz ./zocomputer-cli-0.1.0.tgz
27
+ zo --version
28
+ ```
29
+
30
+ ## Develop locally
31
+
32
+ Install the independent client workspace, then build the CLI:
33
+
34
+ ```sh
35
+ cd clients
36
+ nvm use
37
+ npm ci
38
+ npm run build
39
+ ```
40
+
41
+ The CLI build compiles the SDK first. npm links the two packages inside this
42
+ workspace; application pnpm settings and dependencies are not used.
43
+
44
+ From this package directory:
45
+
46
+ ```sh
47
+ node dist/main.js --help
48
+ node dist/main.js login
49
+ node dist/main.js context list
50
+ node dist/main.js context use wsp_example
51
+ node dist/main.js auth status
52
+ ```
53
+
54
+ Use `node dist/main.js` while working in this repository. The repository's
55
+ internal operator CLI is `./zocli/zo` from the repository root. Run `type -a zo`
56
+ to diagnose a PATH collision; the two clients store separate sign-ins.
57
+
58
+ ## Accounts and workspaces
59
+
60
+ Use named profiles to keep separate sign-ins. New profiles use production:
61
+
62
+ ```sh
63
+ node dist/main.js login --profile work
64
+ node dist/main.js auth status --profile work
65
+ ```
66
+
67
+ A missing `environment` in a saved profile also means production. Internal
68
+ testing can explicitly create a staging profile with
69
+ `login --profile staging --environment staging`. An existing profile keeps its
70
+ environment when you sign in again; changing it requires a different profile.
71
+
72
+ Workspace selection uses `--workspace`, then
73
+ `ZO_WORKSPACE_ID`, then the profile's saved default. An unavailable selected
74
+ workspace produces an error; the CLI never switches identities or workspaces
75
+ to make a command succeed.
76
+
77
+ Login selects a sole authorized workspace automatically. With several
78
+ workspaces, a terminal prompts for a selection; scripts must supply
79
+ `--workspace` or subsequently run `context use`. Status shows the approving
80
+ person separately from the effective subject in the selected workspace.
81
+ `context list`, `context use` and status refresh that view from the server.
82
+
83
+ `login --no-browser` prints the authorization URL on stderr and listens for
84
+ the same loopback callback. It is not a device-code login. The browser must
85
+ reach the CLI's localhost callback.
86
+
87
+ ## Tokens and logout
88
+
89
+ `auth token` explicitly prints a short-lived access token. Add `--headers` to
90
+ print an Authorization JSON object for an MCP client. Status never prints a
91
+ token. `ZO_SYSTEM_API_ACCESS_TOKEN` supplies an explicit automation token for
92
+ the selected profile's fixed origins; the CLI never saves or refreshes it,
93
+ including when the server refuses it.
94
+
95
+ `logout` revokes the saved sign-in and pending replaced sign-ins. If revocation
96
+ fails, local credentials remain so you can retry. `logout --local-only`
97
+ removes local credentials without revoking server access. Unset a bearer
98
+ override before logging in or out of a saved profile.
99
+
100
+ If a credential file is malformed, revoke its login in **Authorized apps**,
101
+ then run `logout --local-only` for that profile before signing in again.
102
+ Ordinary login and logout preserve the unreadable file so they do not silently
103
+ discard pending revocations.
104
+
105
+ ## Local storage
106
+
107
+ Configuration lives in `${XDG_CONFIG_HOME:-$HOME/.config}/zo/cli/`:
108
+
109
+ - `config.json` stores versioned profiles and workspace selections.
110
+ - `credentials/<profile>.json` stores the profile binding, access and refresh
111
+ tokens, refresh state, and pending revocations.
112
+ - `credentials/<profile>.lock/` contains temporary process claims.
113
+
114
+ Directories use mode 0700; files use 0600. The CLI checks ownership and refuses
115
+ symlinks and writable parent directories. Credential writes sync a private
116
+ file, replace the previous file atomically, then sync the directory. These
117
+ permissions protect against other OS users, not programs running as you.
118
+
119
+ Every credential change uses a cross-process bakery lock. Each process
120
+ publishes a unique choosing claim, then an ordered ticket. It waits for lower
121
+ tickets before entering the credential operation. Waits stop after 30 seconds;
122
+ a live owner is never evicted based on elapsed time. Claims from dead PIDs are
123
+ removed. A reused PID can delay recovery until the unrelated process exits;
124
+ it cannot admit two credential writers. This uses native Node APIs on both
125
+ supported systems without depending on Python or native lock extensions.
126
+
127
+ Refresh starts only with 60 seconds or less remaining on the access token.
128
+ Before sending, the CLI durably marks the outcome uncertain. If syncing that
129
+ marker fails after replacement, it attempts to restore the original ready
130
+ credential and reports the storage error; no exchange was sent. A lost response
131
+ may be retried once under the lock within the server's 30-second recovery
132
+ window. Saving the received successor clears the marker. A crash, failure to
133
+ replace the credential file, or unresolved second request leaves the marker
134
+ in place and requires login;
135
+ later processes cannot unknowingly replay that refresh token.
136
+
137
+ Replacing a login saves the new token and the old token's pending revocation
138
+ in one atomic credential write before contacting the revocation endpoint.
139
+ Failed cleanup remains in that journal and is retried by logout or the next
140
+ replacement login. Replacement login reports success with a warning when old
141
+ sign-in cleanup fails; the newly saved credential remains usable. Revoke the
142
+ previous sign-in in Authorized apps if needed. Logout revokes the current
143
+ sign-in too.
144
+
145
+ ## Checks
146
+
147
+ ```sh
148
+ npm run build
149
+ npm exec -- vitest run test/auth.test.ts test/credentials.test.ts test/profiles.test.ts test/context.test.ts
150
+ npm run lint
151
+ npm run typecheck
152
+ npm run test:packed
153
+ ```
154
+
155
+ The packed check builds both public packages and checks their contents. It
156
+ installs their tarballs outside the monorepo and runs the installed binary's
157
+ help, completion, version, RPC, preview and generated commands. A local fake
158
+ API checks that large integers and exact decimals survive invocation. No live
159
+ account or token is needed.
160
+
161
+ To retain release archives for inspection or installation:
162
+
163
+ ```sh
164
+ npm run pack:release -- --out /tmp/zo-cli-release
165
+ ```
166
+
167
+ The command checks generated-command drift and writes both versioned tarballs.
168
+ It rejects source files, missing compiled entry points, internal dependencies
169
+ and unresolved workspace dependencies. It does not publish either package.
package/dist/argv.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface GlobalOptions {
2
+ profile?: string;
3
+ workspace?: string;
4
+ json: boolean;
5
+ envelope: boolean;
6
+ help: boolean;
7
+ }
8
+ export declare function parseGlobals(argv: readonly string[]): {
9
+ args: string[];
10
+ options: GlobalOptions;
11
+ };
12
+ export declare function commandOptions(args: readonly string[], values: readonly string[], flags?: readonly string[]): {
13
+ values: Map<string, string>;
14
+ flags: Set<string>;
15
+ };
package/dist/argv.js ADDED
@@ -0,0 +1,83 @@
1
+ import { CliError } from "./errors.js";
2
+ const switches = new Set(["json", "envelope", "help", "preview"]);
3
+ export function parseGlobals(argv) {
4
+ const options = { json: false, envelope: false, help: false };
5
+ const seen = new Set();
6
+ const args = [];
7
+ let command = false;
8
+ for (let index = 0; index < argv.length; index++) {
9
+ const arg = argv[index];
10
+ if (arg === undefined)
11
+ continue;
12
+ if (!arg.startsWith("--")) {
13
+ args.push(arg);
14
+ command = true;
15
+ continue;
16
+ }
17
+ const equal = arg.indexOf("=");
18
+ const name = arg.slice(2, equal === -1 ? undefined : equal);
19
+ if (!/^[a-z][a-z0-9-]*$/.test(name))
20
+ throw new CliError("Invalid option syntax.");
21
+ const localSwitch = (name === "no-browser" && args[0] === "login") ||
22
+ (name === "local-only" && args[0] === "logout") ||
23
+ (name === "headers" && args[0] === "auth" && args[1] === "token");
24
+ const flag = switches.has(name) || localSwitch || (name === "version" && !command);
25
+ if (flag && equal !== -1)
26
+ throw new CliError(`--${name} does not take a value.`);
27
+ const value = flag
28
+ ? undefined
29
+ : equal === -1
30
+ ? argv[++index]
31
+ : arg.slice(equal + 1);
32
+ if (!flag &&
33
+ (value === undefined || (equal === -1 && value.startsWith("--"))))
34
+ throw new CliError(`--${name} requires a value. Use --name=value for values beginning with --.`);
35
+ if (["profile", "workspace", "json", "envelope", "help"].includes(name)) {
36
+ if (seen.has(name))
37
+ throw new CliError(`Repeated --${name}.`);
38
+ seen.add(name);
39
+ if (name === "profile" || name === "workspace") {
40
+ if (!value)
41
+ throw new CliError(`--${name} requires a nonempty value.`);
42
+ options[name] = value;
43
+ }
44
+ else if (name === "json" || name === "envelope" || name === "help")
45
+ options[name] = true;
46
+ }
47
+ else {
48
+ args.push(arg);
49
+ if (!flag && equal === -1 && value !== undefined)
50
+ args.push(value);
51
+ }
52
+ }
53
+ return { args, options };
54
+ }
55
+ export function commandOptions(args, values, flags = []) {
56
+ const parsed = {
57
+ values: new Map(),
58
+ flags: new Set(),
59
+ };
60
+ for (let index = 0; index < args.length; index++) {
61
+ const arg = args[index];
62
+ if (!arg?.startsWith("--"))
63
+ throw new CliError("Unexpected argument. Run this command with --help.");
64
+ const equal = arg.indexOf("=");
65
+ const name = arg.slice(2, equal === -1 ? undefined : equal);
66
+ if (parsed.values.has(name) || parsed.flags.has(name))
67
+ throw new CliError(`Repeated --${name}.`);
68
+ if (flags.includes(name)) {
69
+ if (equal !== -1)
70
+ throw new CliError(`--${name} does not take a value.`);
71
+ parsed.flags.add(name);
72
+ }
73
+ else if (values.includes(name)) {
74
+ const value = equal === -1 ? args[++index] : arg.slice(equal + 1);
75
+ if (value === undefined || (equal === -1 && value.startsWith("--")))
76
+ throw new CliError(`--${name} requires a value.`);
77
+ parsed.values.set(name, value);
78
+ }
79
+ else
80
+ throw new CliError(`Unknown option --${name}. Run this command with --help.`);
81
+ }
82
+ return parsed;
83
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type LoginOptions } from "./login.ts";
2
+ import { type Profile } from "./profiles.ts";
3
+ import { type Session } from "./session.ts";
4
+ interface Binding {
5
+ issuer: string;
6
+ api: string;
7
+ audience: string;
8
+ clientId: string;
9
+ }
10
+ export interface AuthOptions {
11
+ fetch?: typeof fetch;
12
+ environment?: Binding;
13
+ env?: NodeJS.ProcessEnv;
14
+ now?: () => number;
15
+ signal?: AbortSignal;
16
+ }
17
+ export declare class AuthClient {
18
+ readonly profile: Profile;
19
+ readonly directory: string;
20
+ readonly binding: Binding;
21
+ readonly path: string;
22
+ private readonly fetcher;
23
+ private readonly env;
24
+ private readonly now;
25
+ private readonly signal?;
26
+ constructor(profile: Profile, directory: string, options?: AuthOptions);
27
+ private locked;
28
+ private read;
29
+ private tokenRequest;
30
+ accessToken(): Promise<string>;
31
+ session(): Promise<Session>;
32
+ private revoke;
33
+ private cleanup;
34
+ login(options?: LoginOptions): Promise<{
35
+ cleanupPending: boolean;
36
+ }>;
37
+ logout(localOnly?: boolean): Promise<void>;
38
+ }
39
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,290 @@
1
+ import { join } from "node:path";
2
+ import { CliError, record, throwIfInterrupted } from "./errors.js";
3
+ import { requestJson, RequestFailure } from "./http.js";
4
+ import { loginCode } from "./login.js";
5
+ import { environments, profileName } from "./profiles.js";
6
+ import { parseSession } from "./session.js";
7
+ import { privateDirectory, PrivateWriteDurabilityError, readPrivate, removePrivate, withLock, writePrivate, } from "./storage.js";
8
+ function tokens(raw, now) {
9
+ if (!record(raw) ||
10
+ raw.error !== undefined ||
11
+ typeof raw.access_token !== "string" ||
12
+ !raw.access_token ||
13
+ typeof raw.refresh_token !== "string" ||
14
+ !raw.refresh_token ||
15
+ typeof raw.token_type !== "string" ||
16
+ raw.token_type.toLowerCase() !== "bearer" ||
17
+ typeof raw.expires_in !== "number" ||
18
+ !Number.isSafeInteger(raw.expires_in) ||
19
+ raw.expires_in <= 0 ||
20
+ raw.expires_in > 300)
21
+ throw new RequestFailure(false, "invalid_token_response");
22
+ return {
23
+ accessToken: raw.access_token,
24
+ refreshToken: raw.refresh_token,
25
+ expiresAt: now + raw.expires_in * 1000,
26
+ };
27
+ }
28
+ function invalidCredential() {
29
+ 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");
30
+ }
31
+ export class AuthClient {
32
+ profile;
33
+ directory;
34
+ binding;
35
+ path;
36
+ fetcher;
37
+ env;
38
+ now;
39
+ signal;
40
+ constructor(profile, directory, options = {}) {
41
+ this.profile = profile;
42
+ this.directory = directory;
43
+ this.binding = options.environment ?? environments[profile.environment];
44
+ this.path = join(directory, "credentials", `${profileName(profile.name)}.json`);
45
+ this.fetcher = options.fetch ?? fetch;
46
+ this.env = options.env ?? process.env;
47
+ this.now = options.now ?? Date.now;
48
+ this.signal = options.signal;
49
+ }
50
+ async locked(work, signal = this.signal) {
51
+ throwIfInterrupted(signal);
52
+ await privateDirectory(this.directory);
53
+ await privateDirectory(join(this.directory, "credentials"));
54
+ return withLock(join(this.directory, "credentials", `${this.profile.name}.lock`), work, undefined, signal);
55
+ }
56
+ async read() {
57
+ let raw;
58
+ try {
59
+ raw = await readPrivate(this.path);
60
+ }
61
+ catch (error) {
62
+ if (error instanceof CliError && error.code === "invalid_local_data")
63
+ throw invalidCredential();
64
+ throw error;
65
+ }
66
+ if (raw === undefined)
67
+ return undefined;
68
+ if (!record(raw) ||
69
+ raw.version !== 1 ||
70
+ !record(raw.binding) ||
71
+ !Object.entries(this.binding).every(([key, value]) => record(raw.binding) && raw.binding[key] === value) ||
72
+ !Array.isArray(raw.pendingRevocations) ||
73
+ !raw.pendingRevocations.every((entry) => typeof entry === "string" && entry.length > 0) ||
74
+ (raw.refreshState !== "ready" && raw.refreshState !== "uncertain"))
75
+ throw invalidCredential();
76
+ let active;
77
+ if (raw.active !== undefined) {
78
+ if (!record(raw.active) ||
79
+ typeof raw.active.accessToken !== "string" ||
80
+ !raw.active.accessToken ||
81
+ typeof raw.active.refreshToken !== "string" ||
82
+ !raw.active.refreshToken ||
83
+ typeof raw.active.expiresAt !== "number" ||
84
+ !Number.isFinite(raw.active.expiresAt))
85
+ throw invalidCredential();
86
+ active = {
87
+ accessToken: raw.active.accessToken,
88
+ refreshToken: raw.active.refreshToken,
89
+ expiresAt: raw.active.expiresAt,
90
+ };
91
+ }
92
+ return {
93
+ version: 1,
94
+ binding: this.binding,
95
+ ...(active ? { active } : {}),
96
+ pendingRevocations: raw.pendingRevocations,
97
+ refreshState: raw.refreshState,
98
+ };
99
+ }
100
+ async tokenRequest(form, signal = this.signal) {
101
+ const issuedAt = this.now();
102
+ return tokens(await requestJson(`${this.binding.issuer}/zo-system-api/oauth/token`, {
103
+ method: "POST",
104
+ signal,
105
+ body: new URLSearchParams({
106
+ ...form,
107
+ client_id: this.binding.clientId,
108
+ resource: this.binding.audience,
109
+ }),
110
+ }, this.fetcher), issuedAt);
111
+ }
112
+ async accessToken() {
113
+ throwIfInterrupted(this.signal);
114
+ const override = this.env.ZO_SYSTEM_API_ACCESS_TOKEN;
115
+ if (override !== undefined) {
116
+ if (!override || /\s/.test(override))
117
+ throw new CliError("ZO_SYSTEM_API_ACCESS_TOKEN is empty or invalid.", 3, "invalid_token");
118
+ return override;
119
+ }
120
+ return this.locked(async () => {
121
+ const credential = await this.read();
122
+ throwIfInterrupted(this.signal);
123
+ if (!credential?.active)
124
+ throw new CliError("Sign in with zo login.", 3, "login_required");
125
+ if (credential.refreshState !== "ready")
126
+ throw new CliError("The previous refresh outcome is unknown. Run zo login; automatic refresh is stopped.", 3, "refresh_uncertain");
127
+ if (credential.active.expiresAt - this.now() > 60_000)
128
+ return credential.active.accessToken;
129
+ credential.refreshState = "uncertain";
130
+ try {
131
+ await writePrivate(this.path, credential);
132
+ }
133
+ catch (error) {
134
+ if (error instanceof PrivateWriteDurabilityError) {
135
+ // No exchange was sent, so the visible journal can safely be undone.
136
+ credential.refreshState = "ready";
137
+ await writePrivate(this.path, credential);
138
+ }
139
+ throw error;
140
+ }
141
+ const started = performance.now();
142
+ const startedAt = this.now();
143
+ const form = {
144
+ grant_type: "refresh_token",
145
+ refresh_token: credential.active.refreshToken,
146
+ };
147
+ let successor;
148
+ try {
149
+ successor = await this.tokenRequest(form);
150
+ }
151
+ catch (error) {
152
+ throwIfInterrupted(this.signal);
153
+ // Two eight-second requests fit the issuer's thirty-second recovery
154
+ // window. Wall time also catches suspension of the monotonic clock.
155
+ const elapsed = this.now() - startedAt;
156
+ if (!(error instanceof RequestFailure) ||
157
+ !error.ambiguous ||
158
+ performance.now() - started >= 20_000 ||
159
+ elapsed < 0 ||
160
+ elapsed >= 20_000)
161
+ throw error;
162
+ successor = await this.tokenRequest(form);
163
+ }
164
+ credential.active = successor;
165
+ credential.refreshState = "ready";
166
+ await writePrivate(this.path, credential);
167
+ throwIfInterrupted(this.signal);
168
+ return successor.accessToken;
169
+ });
170
+ }
171
+ async session() {
172
+ const token = await this.accessToken();
173
+ return parseSession(await requestJson(`${this.binding.api}/zo-system-api/v1/session`, { headers: { Authorization: `Bearer ${token}` }, signal: this.signal }, this.fetcher, { systemApi: true, timeoutMs: 30_000 }));
174
+ }
175
+ async revoke(token, signal = this.signal) {
176
+ throwIfInterrupted(signal);
177
+ const timeout = AbortSignal.timeout(8_000);
178
+ let response;
179
+ try {
180
+ response = await this.fetcher(`${this.binding.issuer}/zo-system-api/oauth/revoke`, {
181
+ method: "POST",
182
+ body: new URLSearchParams({
183
+ client_id: this.binding.clientId,
184
+ token,
185
+ token_type_hint: "refresh_token",
186
+ }),
187
+ redirect: "error",
188
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
189
+ });
190
+ }
191
+ catch {
192
+ throwIfInterrupted(signal);
193
+ throw new CliError("Revocation failed. Local credentials were retained; retry zo logout.", 5, "revocation_failed");
194
+ }
195
+ await response.body?.cancel();
196
+ throwIfInterrupted(signal);
197
+ if (!response.ok)
198
+ throw new CliError("Revocation failed. Local credentials were retained; retry zo logout.", 5, "revocation_failed");
199
+ }
200
+ async cleanup(credential, signal = this.signal) {
201
+ while (credential.pendingRevocations.length) {
202
+ const token = credential.pendingRevocations[0];
203
+ if (token === undefined)
204
+ break;
205
+ await this.revoke(token, signal);
206
+ credential.pendingRevocations.shift();
207
+ await writePrivate(this.path, credential);
208
+ throwIfInterrupted(signal);
209
+ }
210
+ }
211
+ async login(options = {}) {
212
+ const signal = this.signal && options.signal
213
+ ? AbortSignal.any([this.signal, options.signal])
214
+ : (this.signal ?? options.signal);
215
+ throwIfInterrupted(signal);
216
+ if (this.env.ZO_SYSTEM_API_ACCESS_TOKEN !== undefined)
217
+ throw new CliError("Unset ZO_SYSTEM_API_ACCESS_TOKEN before browser login.");
218
+ return this.locked(async () => {
219
+ const previous = await this.read();
220
+ 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);
228
+ const credential = {
229
+ version: 1,
230
+ binding: this.binding,
231
+ active,
232
+ refreshState: "ready",
233
+ pendingRevocations: [
234
+ ...(previous?.pendingRevocations ?? []),
235
+ ...(previous?.active ? [previous.active.refreshToken] : []),
236
+ ],
237
+ };
238
+ try {
239
+ await writePrivate(this.path, credential);
240
+ }
241
+ catch (error) {
242
+ if (error instanceof PrivateWriteDurabilityError)
243
+ throw error;
244
+ try {
245
+ // Saving failed; cleanup must still run after an interruption.
246
+ await this.revoke(active.refreshToken, null);
247
+ }
248
+ catch {
249
+ throw new CliError("Could not save or revoke the new sign-in. Revoke Zo CLI in Authorized apps before retrying login.", 5, "unsaved_sign_in");
250
+ }
251
+ throw error;
252
+ }
253
+ throwIfInterrupted(signal);
254
+ try {
255
+ await this.cleanup(credential, signal);
256
+ }
257
+ catch {
258
+ throwIfInterrupted(signal);
259
+ // The new sign-in is durable. Cleanup failure must not send the user
260
+ // to logout, which would revoke that successfully saved replacement.
261
+ return { cleanupPending: true };
262
+ }
263
+ return { cleanupPending: false };
264
+ }, signal);
265
+ }
266
+ async logout(localOnly = false) {
267
+ throwIfInterrupted(this.signal);
268
+ if (this.env.ZO_SYSTEM_API_ACCESS_TOKEN !== undefined)
269
+ throw new CliError("Unset ZO_SYSTEM_API_ACCESS_TOKEN before logging out a saved profile.");
270
+ await this.locked(async () => {
271
+ if (localOnly) {
272
+ await removePrivate(this.path);
273
+ throwIfInterrupted(this.signal);
274
+ return;
275
+ }
276
+ const credential = await this.read();
277
+ throwIfInterrupted(this.signal);
278
+ if (!credential)
279
+ return;
280
+ if (credential.active) {
281
+ await this.revoke(credential.active.refreshToken);
282
+ delete credential.active;
283
+ await writePrivate(this.path, credential);
284
+ }
285
+ await this.cleanup(credential);
286
+ await removePrivate(this.path);
287
+ throwIfInterrupted(this.signal);
288
+ });
289
+ }
290
+ }
@@ -0,0 +1,23 @@
1
+ import { type FetchLike, type RuntimeCatalog } from "@zocomputer/system-api";
2
+ import { type NormalizedCatalog, type OperationVersion } from "@zocomputer/system-api/catalog";
3
+ import { type JsonObject, type JsonValue } from "@zocomputer/system-api/json";
4
+ export interface InvocationContext {
5
+ workspaceId: string;
6
+ apiOrigin: string;
7
+ accessToken: () => Promise<string>;
8
+ fetch?: FetchLike;
9
+ signal?: AbortSignal;
10
+ }
11
+ export interface LoadedCatalog {
12
+ authenticatedContext: InvocationContext;
13
+ catalog: NormalizedCatalog;
14
+ runtimeCatalog: RuntimeCatalog;
15
+ }
16
+ export declare function apiBaseUrl(context: InvocationContext): string;
17
+ export declare function loadCatalog(context: InvocationContext, options?: {
18
+ catalog?: "scoped" | "all";
19
+ }): Promise<LoadedCatalog>;
20
+ export declare function parseVersion(value: string): number;
21
+ export declare function resolveAction(catalog: NormalizedCatalog, action: string, explicitVersion?: number): OperationVersion;
22
+ export declare function listActions(catalog: NormalizedCatalog): JsonValue;
23
+ export declare function describeAction(catalog: NormalizedCatalog, action: string, version?: number): JsonObject;