@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 +168 -2
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +83 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +290 -0
- package/dist/catalog.d.ts +23 -0
- package/dist/catalog.js +193 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +119 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.js +30 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +25 -0
- package/dist/flags.d.ts +27 -0
- package/dist/flags.js +211 -0
- package/dist/generated/commands.d.ts +23 -0
- package/dist/generated/commands.js +1727 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +124 -0
- package/dist/login.d.ts +14 -0
- package/dist/login.js +101 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.js +299 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +221 -0
- package/dist/presentation.d.ts +9 -0
- package/dist/presentation.js +95 -0
- package/dist/profiles.d.ts +37 -0
- package/dist/profiles.js +123 -0
- package/dist/rpc.d.ts +26 -0
- package/dist/rpc.js +208 -0
- package/dist/session.d.ts +17 -0
- package/dist/session.js +48 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +209 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Zo CLI
|
|
2
2
|
|
|
3
|
-
|
|
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;
|