@zocomputer/cli 0.3.0 → 0.4.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 CHANGED
@@ -16,9 +16,9 @@ curl -fsSL https://zo.computer/install.sh | sh
16
16
  Then open a new terminal and run `zo --version`. It includes Node, verifies the archive, and keeps versions under
17
17
  `${XDG_DATA_HOME:-$HOME/.local/share}/zo/cli/` with a managed `~/.local/bin/zo`
18
18
  symlink, and adds `~/.local/bin` to your shell's PATH configuration unless it's
19
- already on PATH. Re-run it to upgrade; `--version`, `--bin-dir`, and `--no-modify-path`
20
- control pinned installs, command placement and shell setup. Existing sign-ins
21
- are preserved. The [CLI guide](../../src/hostagent/system_api/docs/guides/cli.md)
19
+ already on PATH. Run `zo update` to upgrade, or re-run the installer; `--version`,
20
+ `--bin-dir`, and `--no-modify-path` control pinned installs, command placement
21
+ and shell setup. Existing sign-ins are preserved. The [CLI guide](../../src/hostagent/system_api/docs/guides/cli.md)
22
22
  covers supported systems, PATH collisions, rollback and removal.
23
23
 
24
24
  Install the latest published version from npm:
@@ -29,9 +29,9 @@ zo --version
29
29
  zo login
30
30
  ```
31
31
 
32
- Run the same install command again to upgrade. The CLI
33
- installs its matching public SDK dependency. It performs no update check when
34
- you run a command.
32
+ Run the same install command again to upgrade; `zo update` refuses an npm
33
+ installation and names this command. The CLI installs its matching public SDK
34
+ dependency. It performs no update check when you run a command.
35
35
 
36
36
  To try the CLI without installing it globally:
37
37
 
@@ -127,6 +127,43 @@ Each poll waits up to 20 seconds. After a timeout or network error the CLI
127
127
  retries once at once, inside the issuer's 30-second redemption window, then
128
128
  resumes polling every `interval` seconds; `slow_down` and a 429 add 5 seconds.
129
129
 
130
+ ## Output for terminals and scripts
131
+
132
+ `zo actions list`, `zo actions describe`, and commands with schema-derived
133
+ flags revalidate a private disk copy of the workspace catalog on each call.
134
+ The server returns `304` when its `ETag` still matches; the CLI then uses the
135
+ saved copy. A permission error is never served from disk.
136
+
137
+ Results are data. When stdout is piped or redirected, or you pass `--json` or
138
+ `--envelope`, the CLI prints the JSON result for scripts and agents to parse,
139
+ with no color or tables. `rpc`, `--preview`, `actions describe` and
140
+ `auth token --headers` print JSON on a terminal too.
141
+
142
+ When stdout is a terminal, a list command prints a table, any other action
143
+ prints aligned keys and values, and `login`, `context` and `auth status` print
144
+ text. A table shows a few columns of each row and points to `--json` for the
145
+ rest. A key/value view shows the whole result, since the command may not be
146
+ safe to run twice; a result it can't lay out, such as a query's rows, prints
147
+ as JSON.
148
+
149
+ Help, status lines and error wording are prose, read by people and agents
150
+ alike. Help is the same text everywhere: `zo` and `zo --help` print the
151
+ commands in groups with every resource name, and a terminal only adds bold.
152
+ `login` prints the same status lines everywhere too; a terminal adds color, a
153
+ success mark and a spinner, and anything else gets one `Waiting for approval…`
154
+ line. A mistyped command names the nearest installed one.
155
+
156
+ A script can rely on the exit status, the error code and the shape of the
157
+ JSON, and should not match the wording of a message. When stderr is not a
158
+ terminal a failure begins `<code>: <message>`, so the code is there to match.
159
+ On a terminal it reads `Error: <message> (<code>)` with any next step on the
160
+ following line, Ctrl-C prints `Cancelled.`, and `login` shows a spinner while
161
+ it waits for approval. `--json` makes a failure a JSON object either way.
162
+
163
+ Color needs a terminal and an unset or empty `NO_COLOR`. Names and other text
164
+ from the server are escaped before printing, so they cannot send control
165
+ sequences to your terminal.
166
+
130
167
  ## Tokens and logout
131
168
 
132
169
  `auth token` explicitly prints a short-lived access token. Add `--headers` to
@@ -0,0 +1,7 @@
1
+ export interface CachedCatalogExport {
2
+ etag: string;
3
+ catalogDigest: string;
4
+ body: Uint8Array;
5
+ }
6
+ export declare function readCatalogCache(url: URL, directory?: string): Promise<CachedCatalogExport | undefined>;
7
+ export declare function writeCatalogCache(url: URL, entry: CachedCatalogExport, directory?: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { configDirectory, privateDirectory, readPrivate, writePrivate, } from "./storage.js";
4
+ const MAX_CACHE_FILE_BYTES = 6 * 1024 * 1024;
5
+ const MAX_CATALOG_BYTES = 4 * 1024 * 1024;
6
+ const STRONG_ETAG = /^"[0-9a-f]{64}"$/;
7
+ const CATALOG_DIGEST = /^sha256:[0-9a-f]{64}$/;
8
+ function cachePath(url, directory) {
9
+ const key = createHash("sha256").update(url.href).digest("hex");
10
+ return join(directory, `${key}.json`);
11
+ }
12
+ function bodyEtag(body) {
13
+ return `"${createHash("sha256").update(body).digest("hex")}"`;
14
+ }
15
+ export async function readCatalogCache(url, directory = join(configDirectory(), "catalog-cache")) {
16
+ try {
17
+ const path = cachePath(url, directory);
18
+ await privateDirectory(directory);
19
+ const value = await readPrivate(path, MAX_CACHE_FILE_BYTES);
20
+ if (value === null ||
21
+ typeof value !== "object" ||
22
+ Array.isArray(value) ||
23
+ !Object.hasOwn(value, "body") ||
24
+ !Object.hasOwn(value, "etag") ||
25
+ !Object.hasOwn(value, "catalogDigest"))
26
+ return undefined;
27
+ const entry = value;
28
+ if (entry.version !== 1 ||
29
+ typeof entry.body !== "string" ||
30
+ typeof entry.etag !== "string" ||
31
+ typeof entry.catalogDigest !== "string" ||
32
+ !STRONG_ETAG.test(entry.etag) ||
33
+ !CATALOG_DIGEST.test(entry.catalogDigest) ||
34
+ entry.body.length > MAX_CACHE_FILE_BYTES)
35
+ return undefined;
36
+ const body = Buffer.from(entry.body, "base64");
37
+ if (body.length > MAX_CATALOG_BYTES || bodyEtag(body) !== entry.etag)
38
+ return undefined;
39
+ return { body, etag: entry.etag, catalogDigest: entry.catalogDigest };
40
+ }
41
+ catch {
42
+ // A damaged or unavailable optional cache must not block live discovery.
43
+ return undefined;
44
+ }
45
+ }
46
+ export async function writeCatalogCache(url, entry, directory = join(configDirectory(), "catalog-cache")) {
47
+ if (!STRONG_ETAG.test(entry.etag) ||
48
+ !CATALOG_DIGEST.test(entry.catalogDigest) ||
49
+ entry.body.length > MAX_CATALOG_BYTES ||
50
+ bodyEtag(entry.body) !== entry.etag)
51
+ return;
52
+ try {
53
+ await writePrivate(cachePath(url, directory), {
54
+ version: 1,
55
+ etag: entry.etag,
56
+ catalogDigest: entry.catalogDigest,
57
+ body: Buffer.from(entry.body).toString("base64"),
58
+ });
59
+ }
60
+ catch {
61
+ // The disk copy is an optimization, never the source of authorization.
62
+ }
63
+ }
package/dist/catalog.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface InvocationContext {
7
7
  accessToken: () => Promise<string>;
8
8
  fetch?: FetchLike;
9
9
  signal?: AbortSignal;
10
+ /** Test-only override; production uses the private CLI configuration directory. */
11
+ catalogCacheDirectory?: string;
10
12
  }
11
13
  export interface LoadedCatalog {
12
14
  authenticatedContext: InvocationContext;
package/dist/catalog.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { defineRuntimeCatalog, SystemAPIError, } from "@zocomputer/system-api";
2
3
  import { loadNormalizedCatalog, } from "@zocomputer/system-api/catalog";
3
- import { canonicalNumberFromToken, JsonNumber, } from "@zocomputer/system-api/json";
4
+ import { canonicalNumberFromToken, canonicalJsonBytes, JsonNumber, loadsJson, } from "@zocomputer/system-api/json";
4
5
  import { CliError } from "./errors.js";
6
+ import { readCatalogCache, writeCatalogCache } from "./catalog-cache.js";
5
7
  import { systemApiFailure } from "./http.js";
6
8
  const MAX_CATALOG_BYTES = 4 * 1024 * 1024;
7
9
  export function apiBaseUrl(context) {
@@ -45,6 +47,24 @@ async function readCatalog(response) {
45
47
  }
46
48
  return bytes;
47
49
  }
50
+ function verifiedDigest(bytes) {
51
+ const tree = loadsJson(bytes, { maxNodes: null, maxDepth: 40 });
52
+ if (tree === null ||
53
+ typeof tree !== "object" ||
54
+ Array.isArray(tree) ||
55
+ tree instanceof JsonNumber)
56
+ throw invalidCatalog("The service returned an invalid catalog export.");
57
+ const digest = tree.catalog_digest;
58
+ if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest))
59
+ throw invalidCatalog("The service returned an invalid catalog digest.");
60
+ const body = { ...tree };
61
+ delete body.catalog_digest;
62
+ delete body.view_revision;
63
+ const computed = `sha256:${createHash("sha256").update(canonicalJsonBytes(body)).digest("hex")}`;
64
+ if (computed !== digest)
65
+ throw invalidCatalog("The service returned an invalid catalog digest.");
66
+ return digest;
67
+ }
48
68
  export async function loadCatalog(context, options = {}) {
49
69
  if (!/^wsp_[A-Za-z0-9_-]+$/.test(context.workspaceId))
50
70
  throw new CliError("Select a workspace with zo context use <workspace-id> or --workspace.");
@@ -65,16 +85,27 @@ export async function loadCatalog(context, options = {}) {
65
85
  const signal = context.signal
66
86
  ? AbortSignal.any([context.signal, timeout])
67
87
  : timeout;
88
+ const useCache = context.fetch === undefined || context.catalogCacheDirectory !== undefined;
89
+ const cached = useCache
90
+ ? await readCatalogCache(url, context.catalogCacheDirectory)
91
+ : undefined;
68
92
  let response;
69
93
  let bytes;
70
94
  try {
71
95
  response = await (context.fetch ?? fetch)(url, {
72
- headers: { authorization: `Bearer ${token}`, accept: "application/json" },
96
+ headers: {
97
+ authorization: `Bearer ${token}`,
98
+ accept: "application/json",
99
+ ...(cached ? { "If-None-Match": cached.etag } : {}),
100
+ },
73
101
  redirect: "error",
74
102
  cache: "no-store",
75
103
  signal,
76
104
  });
77
- bytes = await readCatalog(response);
105
+ bytes =
106
+ response.status === 304
107
+ ? (cached?.body ?? new Uint8Array())
108
+ : await readCatalog(response);
78
109
  }
79
110
  catch (error) {
80
111
  if (signal.aborted)
@@ -95,8 +126,28 @@ export async function loadCatalog(context, options = {}) {
95
126
  outcome: "not_started",
96
127
  });
97
128
  }
98
- if (!response.ok)
129
+ if (response.status === 304 && !cached)
130
+ throw invalidCatalog("The service returned 304 without a saved catalog.");
131
+ if (!response.ok && response.status !== 304)
99
132
  throw systemApiFailure(response, bytes, "Catalog discovery");
133
+ const etag = response.headers.get("etag");
134
+ if (response.status === 304 && etag !== cached?.etag)
135
+ throw invalidCatalog("The catalog validator changed without a response body.");
136
+ if (response.status === 200 &&
137
+ etag !== null &&
138
+ etag !== `"${createHash("sha256").update(bytes).digest("hex")}"`)
139
+ throw invalidCatalog("The service returned an invalid catalog validator.");
140
+ let digest;
141
+ if (etag !== null || response.status === 304) {
142
+ try {
143
+ digest = verifiedDigest(bytes);
144
+ }
145
+ catch {
146
+ throw invalidCatalog("The service returned an invalid catalog digest.");
147
+ }
148
+ if (response.status === 304 && digest !== cached?.catalogDigest)
149
+ throw invalidCatalog("The saved catalog digest changed.");
150
+ }
100
151
  let catalog;
101
152
  try {
102
153
  catalog = loadNormalizedCatalog(bytes).catalog;
@@ -108,6 +159,8 @@ export async function loadCatalog(context, options = {}) {
108
159
  catalog.selection.catalog !== mode) {
109
160
  throw invalidCatalog("The catalog selection does not match the requested workspace and catalog.");
110
161
  }
162
+ if (response.status === 200 && etag && digest)
163
+ await writeCatalogCache(url, { etag, catalogDigest: digest, body: bytes }, context.catalogCacheDirectory);
111
164
  return {
112
165
  authenticatedContext: { ...context, accessToken: async () => token },
113
166
  catalog,
@@ -1,5 +1,19 @@
1
1
  import { type InvocationContext } from "./catalog.ts";
2
+ import { CliError } from "./errors.ts";
2
3
  import { type RpcResult, type RpcOptions } from "./rpc.ts";
4
+ /** The first word of every built-in command, in catalog order. */
5
+ export declare function resourceNames(): string[];
6
+ /**
7
+ * The error for arguments that name no command. It never repeats what was
8
+ * typed: it names the installed words that did match, and the nearest
9
+ * installed word to the first one that did not.
10
+ */
11
+ export declare function unknownCommand(args: readonly string[], kind?: string): CliError;
12
+ /**
13
+ * The fields a list action sorts by, from installed metadata. Undefined for
14
+ * an action that declares no list query, which is how a list is recognized.
15
+ */
16
+ export declare function listSortFields(action: string): string[] | undefined;
3
17
  export declare function matchBuiltinCommand(args: readonly string[]): {
4
18
  action: string;
5
19
  args: string[];
package/dist/commands.js CHANGED
@@ -4,6 +4,84 @@ import { CliError, safeText } from "./errors.js";
4
4
  import { encodeCommandFlags, inputFlags, parseCommandFlags, reservedOptions, resolvedSchema, scalarType, schemaObject, } from "./flags.js";
5
5
  import { commandManifest } from "./generated/commands.js";
6
6
  import { inputJson, runRpc } from "./rpc.js";
7
+ import { closest } from "./suggest.js";
8
+ /** Commands the runner implements itself, each with its subcommands. */
9
+ const localCommands = {
10
+ login: [],
11
+ logout: [],
12
+ auth: ["status", "token"],
13
+ context: ["list", "use", "show"],
14
+ actions: ["list", "describe"],
15
+ rpc: [],
16
+ completion: [],
17
+ update: [],
18
+ };
19
+ /** The first word of every built-in command, in catalog order. */
20
+ export function resourceNames() {
21
+ return [
22
+ ...new Set(commandManifest.commands.flatMap((command) => command.path.slice(0, 1))),
23
+ ];
24
+ }
25
+ /**
26
+ * The error for arguments that name no command. It never repeats what was
27
+ * typed: it names the installed words that did match, and the nearest
28
+ * installed word to the first one that did not.
29
+ */
30
+ export function unknownCommand(args, kind = "command") {
31
+ let paths = [
32
+ ...Object.entries(localCommands).flatMap(([name, subcommands]) => subcommands.length
33
+ ? subcommands.map((subcommand) => [name, subcommand])
34
+ : [[name]]),
35
+ ...commandManifest.commands.map((command) => command.path),
36
+ ];
37
+ let depth = 0;
38
+ for (; depth < args.length; depth++) {
39
+ const matching = paths.filter((path) => path[depth] === args[depth]);
40
+ if (!matching.length)
41
+ break;
42
+ paths = matching;
43
+ }
44
+ const next = [
45
+ ...new Set(paths.flatMap((path) => {
46
+ const word = path[depth];
47
+ return word === undefined ? [] : [word];
48
+ })),
49
+ ];
50
+ const known = paths[0]?.slice(0, depth) ?? [];
51
+ const prefix = ["zo", ...known].join(" ");
52
+ const help = `Run ${prefix} --help.`;
53
+ const typed = args[depth];
54
+ if (depth > 0 &&
55
+ next.length &&
56
+ (typed === undefined || typed.startsWith("--")))
57
+ return new CliError(`${prefix} needs a command. ${help}`);
58
+ const guess = typed === undefined || typed.startsWith("--")
59
+ ? undefined
60
+ : closest(typed, next);
61
+ return new CliError(`Unknown ${depth ? `${known.join(" ")} command` : kind}.${guess ? ` Did you mean ${prefix} ${guess}?` : ""} ${help}`);
62
+ }
63
+ /**
64
+ * The fields a list action sorts by, from installed metadata. Undefined for
65
+ * an action that declares no list query, which is how a list is recognized.
66
+ */
67
+ export function listSortFields(action) {
68
+ const queries = (commandManifest.commands.find((command) => command.action === action)
69
+ ?.versions ?? []).flatMap((version) => "queryJson" in version && typeof version.queryJson === "string"
70
+ ? [version.queryJson]
71
+ : []);
72
+ if (!queries.length)
73
+ return undefined;
74
+ const fields = new Set();
75
+ for (const text of queries) {
76
+ const query = loadsJson(text);
77
+ const sort = schemaObject(resolvedSchema(query)?.properties)?.sort;
78
+ const choices = sort === undefined ? undefined : resolvedSchema(sort, query)?.enum;
79
+ for (const choice of Array.isArray(choices) ? choices : [])
80
+ if (typeof choice === "string")
81
+ fields.add(choice.replace(/\.(?:asc|desc)$/, ""));
82
+ }
83
+ return [...fields];
84
+ }
7
85
  export function matchBuiltinCommand(args) {
8
86
  const command = commandManifest.commands.find((command) => command.path.every((segment, index) => args[index] === segment));
9
87
  return command
@@ -47,7 +125,7 @@ Async actions return their actual accepted/run/operation state; there is no univ
47
125
  export function builtinHelp(path = []) {
48
126
  const commands = commandManifest.commands.filter((command) => path.every((segment, index) => command.path[index] === segment));
49
127
  if (!commands.length)
50
- throw new CliError("Unknown built-in command. Run zo --help.");
128
+ throw unknownCommand(path, "built-in command");
51
129
  const command = commands.find((command) => command.path.length === path.length);
52
130
  if (!command)
53
131
  return `Built-in commands (installed metadata; availability is checked live)\n\n${commands.map((item) => ` zo ${item.path.join(" ")}`).join("\n")}\n\nRun a command with --help for its schema-derived flags.\n`;
@@ -90,19 +168,10 @@ export function builtinHelp(path = []) {
90
168
  export function completionScript(shell) {
91
169
  const words = [
92
170
  ...new Set([
93
- "login",
94
- "logout",
95
- "auth",
96
- "context",
97
- "actions",
98
- "rpc",
99
- "completion",
100
- "status",
101
- "token",
102
- "list",
103
- "describe",
104
- "use",
105
- "show",
171
+ ...Object.entries(localCommands).flatMap(([name, subcommands]) => [
172
+ name,
173
+ ...subcommands,
174
+ ]),
106
175
  ...[...reservedOptions].map((option) => `--${option}`),
107
176
  ...commandManifest.commands.flatMap((command) => command.path),
108
177
  ...commandManifest.commands.flatMap((command) => command.versions.flatMap((version) => inputFlags(loadsJson(version.inputJson)).map((flag) => `--${flag.name}`))),