@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/dist/output.js ADDED
@@ -0,0 +1,221 @@
1
+ import { SystemAPIError } from "@zocomputer/system-api";
2
+ import { canonicalNumberFromToken, dumpsJson, JsonNumber, loadsJson, } from "@zocomputer/system-api/json";
3
+ import { CliError, record, safeText } from "./errors.js";
4
+ function toJson(value) {
5
+ if (value === null ||
6
+ typeof value === "string" ||
7
+ typeof value === "boolean" ||
8
+ value instanceof JsonNumber)
9
+ return value;
10
+ if (typeof value === "number" && Number.isFinite(value))
11
+ return canonicalNumberFromToken(String(value));
12
+ if (Array.isArray(value))
13
+ return value.map(toJson);
14
+ if (record(value))
15
+ return Object.fromEntries(Object.entries(value)
16
+ .filter(([, entry]) => entry !== undefined)
17
+ .map(([key, entry]) => [key, toJson(entry)]));
18
+ throw new TypeError("Output must be JSON.");
19
+ }
20
+ function prettyJson(value, depth = 0) {
21
+ if (value === null ||
22
+ typeof value !== "object" ||
23
+ value instanceof JsonNumber)
24
+ return dumpsJson(value);
25
+ const indent = " ".repeat(depth + 1);
26
+ const closing = " ".repeat(depth);
27
+ if (Array.isArray(value))
28
+ return value.length
29
+ ? `[\n${value.map((entry) => `${indent}${prettyJson(entry, depth + 1)}`).join(",\n")}\n${closing}]`
30
+ : "[]";
31
+ const entries = Object.entries(value);
32
+ return entries.length
33
+ ? `{\n${entries.map(([key, entry]) => `${indent}${JSON.stringify(key)}: ${prettyJson(entry, depth + 1)}`).join(",\n")}\n${closing}}`
34
+ : "{}";
35
+ }
36
+ function terminalJson(text) {
37
+ return text.replace(/[\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
38
+ }
39
+ export function writeJsonText(text, options = {}) {
40
+ const stdout = options.stdout ?? process.stdout;
41
+ const human = stdout.isTTY && !options.json && !options.envelope;
42
+ stdout.write(`${human ? terminalJson(prettyJson(loadsJson(text, { maxNodes: null, maxDepth: 256 }))) : text}\n`);
43
+ }
44
+ export function writeJson(value, options = {}) {
45
+ writeJsonText(dumpsJson(toJson(value)), options);
46
+ }
47
+ export function writeRpcResult(result, options = {}) {
48
+ if (result.kind === "preview") {
49
+ writeJsonText(`{"workspace_id":${JSON.stringify(result.workspaceId)},"request":${result.requestJson}}`, options);
50
+ return;
51
+ }
52
+ writeJsonText(options.envelope
53
+ ? result.response.envelopeJson
54
+ : result.response.resultJson, options);
55
+ }
56
+ const detailFields = {
57
+ invalid_envelope: ["reason"],
58
+ conflict: ["reason", "pointer"],
59
+ quota_exceeded: ["reason"],
60
+ invalid_input: ["violations"],
61
+ invalid_query: ["violations"],
62
+ invalid_implementation_output: ["violations"],
63
+ approval_required: ["reference"],
64
+ unsupported_version: ["available_versions"],
65
+ rate_limited: ["retry_after_seconds"],
66
+ invalid_definition: ["pointer", "reason"],
67
+ missing_grants: ["function_ids", "total"],
68
+ permission_denied: [
69
+ "subscription_ids",
70
+ "total",
71
+ "connection_id",
72
+ "action",
73
+ "reason",
74
+ ],
75
+ };
76
+ function boundedString(value, empty = false) {
77
+ return (typeof value === "string" &&
78
+ (empty || value.length > 0) &&
79
+ value.length <= 256);
80
+ }
81
+ function boundedInteger(value, maximum, minimum = 0) {
82
+ if (value instanceof JsonNumber && !value.isInteger)
83
+ return undefined;
84
+ const number = value instanceof JsonNumber ? Number(value.text) : value;
85
+ return typeof number === "number" &&
86
+ Number.isInteger(number) &&
87
+ number >= minimum &&
88
+ number <= maximum
89
+ ? canonicalNumberFromToken(String(number))
90
+ : undefined;
91
+ }
92
+ /** Mirror the public error detail fields in hostagent.system_api.errors. */
93
+ export function safeDetails(code, details) {
94
+ if (!record(details))
95
+ return {};
96
+ const projected = {};
97
+ const fields = detailFields[code] ?? [];
98
+ for (const field of fields) {
99
+ const value = details[field];
100
+ if (["reason", "reference", "connection_id", "action", "pointer"].includes(field)) {
101
+ if (boundedString(value, field === "pointer"))
102
+ projected[field] = value;
103
+ }
104
+ else if (field === "total" || field === "retry_after_seconds") {
105
+ const integer = boundedInteger(value, field === "total" ? 2_147_483_647 : 86_400);
106
+ if (integer !== undefined)
107
+ projected[field] = integer;
108
+ }
109
+ else if (Array.isArray(value) && value.length <= 20) {
110
+ if (field === "violations") {
111
+ projected[field] = value.flatMap((item) => record(item) &&
112
+ boundedString(item.code) &&
113
+ boundedString(item.path, true)
114
+ ? [{ code: item.code, path: item.path }]
115
+ : []);
116
+ }
117
+ else if (field === "available_versions") {
118
+ projected[field] = value.flatMap((item) => {
119
+ const integer = boundedInteger(item, 2_147_483_647, 1);
120
+ return integer === undefined ? [] : [integer];
121
+ });
122
+ }
123
+ else if (field === "function_ids" || field === "subscription_ids")
124
+ projected[field] = value.filter((item) => boundedString(item));
125
+ }
126
+ }
127
+ if (details.outcome === "unknown" || details.outcome === "not_started")
128
+ projected.outcome = details.outcome;
129
+ return projected;
130
+ }
131
+ export function exitCode(error) {
132
+ if (error instanceof CliError)
133
+ return error.exitCode;
134
+ if (error instanceof SystemAPIError) {
135
+ if (error.code === "interrupted")
136
+ return 130;
137
+ if (error.code === "unauthenticated" || error.httpStatus === 401)
138
+ return 3;
139
+ if ([
140
+ "insufficient_scope",
141
+ "permission_denied",
142
+ "missing_grants",
143
+ "approval_required",
144
+ ].includes(error.code) ||
145
+ error.httpStatus === 403)
146
+ return 4;
147
+ if (error.httpStatus === undefined &&
148
+ error.outcome === "not_started" &&
149
+ [
150
+ "invalid_input",
151
+ "invalid_query",
152
+ "invalid_envelope",
153
+ "version_required",
154
+ "unsupported_version",
155
+ "action_not_found",
156
+ ].includes(error.code))
157
+ return 2;
158
+ return 5;
159
+ }
160
+ return error instanceof Error && error.name === "AbortError" ? 130 : 5;
161
+ }
162
+ function actionHint(error) {
163
+ if (error.code === "unauthenticated" || error.httpStatus === 401)
164
+ return "Run zo login for this profile, then explicitly repeat the command if appropriate.";
165
+ if (error.code === "insufficient_scope")
166
+ return "Run zo login and approve the selected workspace for this profile.";
167
+ if (error.code === "approval_required")
168
+ return "Complete the required approval before explicitly repeating the command.";
169
+ if (error.code === "permission_denied" || error.code === "missing_grants")
170
+ return "Ask a workspace owner to grant the selected identity access to this action or resource.";
171
+ return undefined;
172
+ }
173
+ export function writeError(error, options = {}) {
174
+ const status = exitCode(error);
175
+ const stderr = options.stderr ?? process.stderr;
176
+ const known = error instanceof CliError || error instanceof SystemAPIError;
177
+ const failure = {
178
+ code: known
179
+ ? error.code
180
+ : status === 130
181
+ ? "interrupted"
182
+ : "command_failed",
183
+ message: known
184
+ ? error.message
185
+ : status === 130
186
+ ? "Interrupted."
187
+ : "The command failed. Check your connection and local file permissions.",
188
+ };
189
+ if (error instanceof SystemAPIError) {
190
+ const details = safeDetails(error.code, error.details);
191
+ if (Object.keys(details).length > 0)
192
+ failure.details = details;
193
+ if (error.requestId !== undefined)
194
+ failure.request_id = error.requestId;
195
+ if (error.httpStatus !== undefined)
196
+ failure.http_status = canonicalNumberFromToken(String(error.httpStatus));
197
+ if (error.retryAfterSeconds !== undefined)
198
+ failure.retry_after_seconds = canonicalNumberFromToken(String(error.retryAfterSeconds));
199
+ if (error.outcome !== undefined)
200
+ failure.outcome = error.outcome;
201
+ const hint = actionHint(error);
202
+ if (hint !== undefined)
203
+ failure.hint = hint;
204
+ }
205
+ if (options.json)
206
+ stderr.write(`${dumpsJson({ error: failure })}\n`);
207
+ else {
208
+ stderr.write(`${safeText(String(failure.code))}: ${safeText(String(failure.message))}\n`);
209
+ if (failure.request_id !== undefined)
210
+ stderr.write(`Request: ${safeText(String(failure.request_id))}\n`);
211
+ if (failure.details !== undefined)
212
+ stderr.write(`Details: ${terminalJson(dumpsJson(failure.details))}\n`);
213
+ if (failure.retry_after_seconds !== undefined)
214
+ stderr.write(`Retry after: ${dumpsJson(failure.retry_after_seconds)} seconds.\n`);
215
+ if (failure.outcome !== undefined)
216
+ stderr.write(`Outcome: ${safeText(String(failure.outcome))}.${failure.outcome === "unknown" ? " The action may have run; the CLI did not replay it." : ""}\n`);
217
+ if (failure.hint !== undefined)
218
+ stderr.write(`${safeText(String(failure.hint))}\n`);
219
+ }
220
+ return status;
221
+ }
@@ -0,0 +1,9 @@
1
+ import { type OutputOptions } from "./output.ts";
2
+ import type { RpcResult } from "./rpc.ts";
3
+ export interface PresentationOptions extends OutputOptions {
4
+ workspaceId?: string;
5
+ identity?: string;
6
+ columns?: number;
7
+ }
8
+ export declare function tableResult(action: string, text: string, columns?: number): string | undefined;
9
+ export declare function writeBuiltinResult(action: string, result: RpcResult, options?: PresentationOptions): void;
@@ -0,0 +1,95 @@
1
+ import { dumpsJson, loadsJson, JsonNumber, } from "@zocomputer/system-api/json";
2
+ import { safeText } from "./errors.js";
3
+ import { schemaObject } from "./flags.js";
4
+ import { writeRpcResult } from "./output.js";
5
+ const tables = {
6
+ "zo.machine.list": {
7
+ field: "machines",
8
+ columns: ["id", "display_name", "state"],
9
+ },
10
+ "zo.database.list": {
11
+ field: "databases",
12
+ columns: ["id", "display_name", "state"],
13
+ },
14
+ "zo.agent.list": { field: "agents", columns: ["id", "display_name"] },
15
+ "zo.connection.list": {
16
+ field: "connections",
17
+ columns: ["id", "display_name", "status"],
18
+ },
19
+ "zo.function.list": { field: "functions", columns: ["id", "display_name"] },
20
+ "zo.task.list": { field: "tasks", columns: ["id", "title", "status"] },
21
+ "zo.workflow.list": { field: "workflows", columns: ["id", "display_name"] },
22
+ "zo.activity.list": {
23
+ field: "activity",
24
+ columns: ["occurred_at", "action", "outcome", "summary"],
25
+ },
26
+ };
27
+ function cell(value) {
28
+ if (value === undefined || value === null)
29
+ return "-";
30
+ return safeText(typeof value === "string"
31
+ ? value
32
+ : value instanceof JsonNumber
33
+ ? value.text
34
+ : dumpsJson(value));
35
+ }
36
+ function clipped(text, width) {
37
+ const characters = Array.from(text);
38
+ return characters.length <= width
39
+ ? text.padEnd(width)
40
+ : `${characters.slice(0, width - 1).join("")}…`;
41
+ }
42
+ export function tableResult(action, text, columns = 100) {
43
+ const projection = tables[action];
44
+ if (!projection)
45
+ return undefined;
46
+ const result = schemaObject(loadsJson(text, { maxNodes: null, maxDepth: 256 }));
47
+ const rows = result?.[projection.field];
48
+ if (!result || !Array.isArray(rows))
49
+ return undefined;
50
+ if (rows.some((row) => {
51
+ const object = schemaObject(row);
52
+ return (!object ||
53
+ projection.columns.some((field) => !Object.hasOwn(object, field) ||
54
+ (object[field] !== null &&
55
+ typeof object[field] === "object" &&
56
+ !(object[field] instanceof JsonNumber))));
57
+ }))
58
+ return undefined;
59
+ const cells = rows.map((row) => projection.columns.map((field) => cell(schemaObject(row)?.[field])));
60
+ const width = Math.max(20, Math.floor(columns));
61
+ const visible = projection.columns.slice(0, Math.max(1, Math.floor((width + 2) / 12)));
62
+ const budget = Math.floor((width - (visible.length - 1) * 2) / visible.length);
63
+ const widths = visible.map((column, index) => Math.min(budget, Math.max(column.length, ...cells.map((row) => Array.from(row[index] ?? "").length))));
64
+ const format = (row) => visible
65
+ .map((_, index) => clipped(row[index] ?? "", widths[index] ?? budget))
66
+ .join(" ")
67
+ .trimEnd();
68
+ const lines = [
69
+ format(visible.map((column) => column.toUpperCase())),
70
+ ...cells.map(format),
71
+ ];
72
+ if (!rows.length)
73
+ lines.push("No results.");
74
+ if (result.next_cursor !== undefined && result.next_cursor !== null)
75
+ lines.push(`Next cursor: ${cell(result.next_cursor)}`);
76
+ lines.push("Use --json for the complete result.");
77
+ return `${lines.join("\n")}\n`;
78
+ }
79
+ export function writeBuiltinResult(action, result, options = {}) {
80
+ const stdout = options.stdout ?? process.stdout;
81
+ const stderr = options.stderr ?? process.stderr;
82
+ if (stdout.isTTY &&
83
+ !options.json &&
84
+ !options.envelope &&
85
+ result.kind === "success") {
86
+ if (options.workspaceId)
87
+ stderr.write(`Workspace: ${safeText(options.workspaceId)}${options.identity ? ` Identity: ${safeText(options.identity)}` : ""}\n`);
88
+ const table = tableResult(action, result.response.resultJson, options.columns ?? process.stdout.columns ?? 100);
89
+ if (table !== undefined) {
90
+ stdout.write(table);
91
+ return;
92
+ }
93
+ }
94
+ writeRpcResult(result, options);
95
+ }
@@ -0,0 +1,37 @@
1
+ export declare const environments: {
2
+ readonly production: {
3
+ readonly issuer: "https://auth.zo.computer";
4
+ readonly api: "https://api.zo.computer";
5
+ readonly audience: "https://api.zo.computer/zo-system-api";
6
+ readonly clientId: "zo-public-cli";
7
+ };
8
+ readonly staging: {
9
+ readonly issuer: "https://auth.zostaging.computer";
10
+ readonly api: "https://api.zostaging.computer";
11
+ readonly audience: "https://api.zostaging.computer/zo-system-api";
12
+ readonly clientId: "zo-public-cli";
13
+ };
14
+ };
15
+ export type Environment = keyof typeof environments;
16
+ export interface Profile {
17
+ name: string;
18
+ environment: Environment;
19
+ workspaceId?: string;
20
+ }
21
+ interface Configuration {
22
+ version: 1;
23
+ activeProfile: string;
24
+ profiles: Record<string, Profile>;
25
+ }
26
+ export declare function profileName(name: string): string;
27
+ export declare function workspaceId(value: string): string;
28
+ export declare class Profiles {
29
+ readonly directory: string;
30
+ constructor(directory?: string);
31
+ read(): Promise<Configuration>;
32
+ resolve(name?: string): Promise<Profile>;
33
+ create(name?: string, environment?: Environment, signal?: AbortSignal): Promise<Profile>;
34
+ select(profile: Profile, selected: string, signal?: AbortSignal): Promise<void>;
35
+ }
36
+ export declare function selectedWorkspace(profile: Profile, explicit?: string, env?: NodeJS.ProcessEnv): string;
37
+ export {};
@@ -0,0 +1,123 @@
1
+ import { join } from "node:path";
2
+ import { CliError, record, throwIfInterrupted } from "./errors.js";
3
+ import { configDirectory, privateDirectory, readPrivate, withLock, writePrivate, } from "./storage.js";
4
+ export const environments = {
5
+ production: {
6
+ issuer: "https://auth.zo.computer",
7
+ api: "https://api.zo.computer",
8
+ audience: "https://api.zo.computer/zo-system-api",
9
+ clientId: "zo-public-cli",
10
+ },
11
+ staging: {
12
+ issuer: "https://auth.zostaging.computer",
13
+ api: "https://api.zostaging.computer",
14
+ audience: "https://api.zostaging.computer/zo-system-api",
15
+ clientId: "zo-public-cli",
16
+ },
17
+ };
18
+ export function profileName(name) {
19
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
20
+ throw new CliError("Profile names must use letters, numbers, underscores or hyphens (1–64 characters).");
21
+ return name;
22
+ }
23
+ export function workspaceId(value) {
24
+ if (!/^wsp_[A-Za-z0-9_-]+$/.test(value))
25
+ throw new CliError("Use a workspace ID beginning with wsp_.");
26
+ return value;
27
+ }
28
+ export class Profiles {
29
+ directory;
30
+ constructor(directory = configDirectory()) {
31
+ this.directory = directory;
32
+ }
33
+ async read() {
34
+ await privateDirectory(this.directory);
35
+ const raw = await readPrivate(join(this.directory, "config.json"));
36
+ if (raw === undefined)
37
+ return { version: 1, activeProfile: "default", profiles: {} };
38
+ if (!record(raw) ||
39
+ raw.version !== 1 ||
40
+ typeof raw.activeProfile !== "string" ||
41
+ !record(raw.profiles))
42
+ throw new CliError("Invalid profile configuration.");
43
+ const profiles = {};
44
+ for (const [name, entry] of Object.entries(raw.profiles)) {
45
+ profileName(name);
46
+ if (!record(entry) ||
47
+ entry.name !== name ||
48
+ (entry.environment !== undefined &&
49
+ entry.environment !== "production" &&
50
+ entry.environment !== "staging") ||
51
+ (entry.workspaceId !== undefined &&
52
+ typeof entry.workspaceId !== "string"))
53
+ throw new CliError("Invalid profile configuration.");
54
+ profiles[name] = {
55
+ name,
56
+ environment: entry.environment ?? "production",
57
+ ...(typeof entry.workspaceId === "string"
58
+ ? { workspaceId: workspaceId(entry.workspaceId) }
59
+ : {}),
60
+ };
61
+ }
62
+ return {
63
+ version: 1,
64
+ activeProfile: profileName(raw.activeProfile),
65
+ profiles,
66
+ };
67
+ }
68
+ async resolve(name) {
69
+ const config = await this.read();
70
+ const selected = profileName(name ?? config.activeProfile);
71
+ const saved = Object.hasOwn(config.profiles, selected)
72
+ ? config.profiles[selected]
73
+ : undefined;
74
+ if (saved)
75
+ return saved;
76
+ if (selected === "default")
77
+ return { name: selected, environment: "production" };
78
+ throw new CliError("Unknown profile. Create it with zo login --profile <name>.");
79
+ }
80
+ async create(name, environment, signal) {
81
+ return withLock(join(this.directory, "config.lock"), async () => {
82
+ const config = await this.read();
83
+ throwIfInterrupted(signal);
84
+ const selected = profileName(name ?? config.activeProfile);
85
+ const existing = Object.hasOwn(config.profiles, selected)
86
+ ? config.profiles[selected]
87
+ : undefined;
88
+ if (existing &&
89
+ environment !== undefined &&
90
+ existing.environment !== environment)
91
+ throw new CliError("A profile's environment cannot change. Choose a new profile name.");
92
+ const profile = existing ?? {
93
+ name: selected,
94
+ environment: environment ?? "production",
95
+ };
96
+ config.profiles[selected] = profile;
97
+ await writePrivate(join(this.directory, "config.json"), config);
98
+ throwIfInterrupted(signal);
99
+ return profile;
100
+ }, undefined, signal);
101
+ }
102
+ async select(profile, selected, signal) {
103
+ workspaceId(selected);
104
+ await withLock(join(this.directory, "config.lock"), async () => {
105
+ const config = await this.read();
106
+ throwIfInterrupted(signal);
107
+ const saved = Object.hasOwn(config.profiles, profile.name)
108
+ ? config.profiles[profile.name]
109
+ : undefined;
110
+ if (saved && saved.environment !== profile.environment)
111
+ throw new CliError("Profile environment changed.");
112
+ config.profiles[profile.name] = { ...profile, workspaceId: selected };
113
+ await writePrivate(join(this.directory, "config.json"), config);
114
+ throwIfInterrupted(signal);
115
+ }, undefined, signal);
116
+ }
117
+ }
118
+ export function selectedWorkspace(profile, explicit, env = process.env) {
119
+ const selected = explicit ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
120
+ if (!selected)
121
+ throw new CliError("Select a workspace with zo context use <workspace-id> or --workspace.");
122
+ return workspaceId(selected);
123
+ }
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type JsonCallResult } from "@zocomputer/system-api";
2
+ import { type JsonObject } from "@zocomputer/system-api/json";
3
+ import { type InvocationContext, type LoadedCatalog } from "./catalog.ts";
4
+ export interface RpcOptions {
5
+ action: string;
6
+ input?: string;
7
+ inputFile?: string;
8
+ version?: number;
9
+ query?: string;
10
+ idempotencyKey?: string;
11
+ preview?: boolean;
12
+ stdin?: AsyncIterable<Uint8Array | string>;
13
+ }
14
+ export type RpcResult = {
15
+ kind: "success";
16
+ response: JsonCallResult;
17
+ } | {
18
+ kind: "preview";
19
+ workspaceId: string;
20
+ requestJson: string;
21
+ };
22
+ export declare function parseJsonObject(text: string, label?: string): JsonObject;
23
+ export declare function inputJson(options: Pick<RpcOptions, "input" | "inputFile" | "stdin"> & {
24
+ signal?: AbortSignal;
25
+ }): Promise<string>;
26
+ export declare function runRpc(context: InvocationContext, options: RpcOptions, loadedCatalog?: LoadedCatalog): Promise<RpcResult>;