@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 +43 -6
- package/dist/catalog-cache.d.ts +7 -0
- package/dist/catalog-cache.js +63 -0
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +57 -4
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +83 -14
- package/dist/generated/commands.js +38 -13
- package/dist/help.d.ts +7 -0
- package/dist/help.js +43 -0
- package/dist/output.d.ts +7 -9
- package/dist/output.js +32 -4
- package/dist/presentation.d.ts +5 -1
- package/dist/presentation.js +91 -42
- package/dist/render.d.ts +26 -0
- package/dist/render.js +145 -0
- package/dist/rpc.js +1 -1
- package/dist/runner.d.ts +0 -1
- package/dist/runner.js +118 -51
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +2 -2
- package/dist/style.d.ts +40 -0
- package/dist/style.js +74 -0
- package/dist/suggest.d.ts +5 -0
- package/dist/suggest.js +42 -0
- package/dist/update.d.ts +21 -0
- package/dist/update.js +195 -0
- package/package.json +2 -2
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type JsonObject, type JsonValue } from "@zocomputer/system-api/json";
|
|
2
|
+
import type { Style } from "./style.ts";
|
|
3
|
+
/** Ends a table: it shows a few columns of each row. */
|
|
4
|
+
export declare const completeResultHint = "Use --json for the complete result.";
|
|
5
|
+
/** Ends a key/value view, which leaves nothing out. */
|
|
6
|
+
export declare const jsonHint = "Use --json for JSON.";
|
|
7
|
+
export declare function jsonObject(value: JsonValue | undefined): JsonObject | undefined;
|
|
8
|
+
export declare function isScalar(value: JsonValue | undefined): boolean;
|
|
9
|
+
/** Plain, or a small object of plain values such as `{type, id}`. */
|
|
10
|
+
export declare function fitsCell(value: JsonValue | undefined): boolean;
|
|
11
|
+
/** One value as escaped single-line text. */
|
|
12
|
+
export declare function cell(value: JsonValue | undefined): string;
|
|
13
|
+
/**
|
|
14
|
+
* Lines of an aligned table no wider than `width`: a header, then one line a
|
|
15
|
+
* row. Cells must already be escaped. A narrow terminal drops trailing columns
|
|
16
|
+
* and shortens the widest ones.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tableLines(headers: readonly string[], rows: readonly (readonly string[])[], width: number, style: Style): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Lines of an aligned key/value view of one object, with nothing left out or
|
|
21
|
+
* shortened: a person may not be able to run the command again to see the
|
|
22
|
+
* rest. Nested objects, lists of objects and text with line breaks are
|
|
23
|
+
* indented under their key. Returns undefined for a shape this layout can't
|
|
24
|
+
* hold (lists of lists, mixed lists, deep nesting); the caller prints JSON.
|
|
25
|
+
*/
|
|
26
|
+
export declare function detailLines(value: JsonObject, style: Style, depth?: number): string[] | undefined;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { dumpsJson, JsonNumber, } from "@zocomputer/system-api/json";
|
|
2
|
+
import { safeText } from "./errors.js";
|
|
3
|
+
/** Ends a table: it shows a few columns of each row. */
|
|
4
|
+
export const completeResultHint = "Use --json for the complete result.";
|
|
5
|
+
/** Ends a key/value view, which leaves nothing out. */
|
|
6
|
+
export const jsonHint = "Use --json for JSON.";
|
|
7
|
+
// Results nested deeper than this are easier to read as JSON.
|
|
8
|
+
const MAX_DEPTH = 4;
|
|
9
|
+
// A nested object with at most this many plain values fits one table cell.
|
|
10
|
+
const CELL_FIELDS = 4;
|
|
11
|
+
export function jsonObject(value) {
|
|
12
|
+
return value !== null &&
|
|
13
|
+
typeof value === "object" &&
|
|
14
|
+
!Array.isArray(value) &&
|
|
15
|
+
!(value instanceof JsonNumber)
|
|
16
|
+
? value
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
export function isScalar(value) {
|
|
20
|
+
return (value === undefined ||
|
|
21
|
+
value === null ||
|
|
22
|
+
typeof value !== "object" ||
|
|
23
|
+
value instanceof JsonNumber);
|
|
24
|
+
}
|
|
25
|
+
/** Plain, or a small object of plain values such as `{type, id}`. */
|
|
26
|
+
export function fitsCell(value) {
|
|
27
|
+
if (isScalar(value))
|
|
28
|
+
return true;
|
|
29
|
+
const values = Object.values(jsonObject(value) ?? {});
|
|
30
|
+
return (values.length > 0 && values.length <= CELL_FIELDS && values.every(isScalar));
|
|
31
|
+
}
|
|
32
|
+
/** One value as escaped single-line text. */
|
|
33
|
+
export function cell(value) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
return "-";
|
|
36
|
+
const nested = jsonObject(value);
|
|
37
|
+
if (nested && fitsCell(value))
|
|
38
|
+
return (Object.values(nested)
|
|
39
|
+
.filter((entry) => entry !== null)
|
|
40
|
+
.map(cell)
|
|
41
|
+
.join(" ") || "-");
|
|
42
|
+
return safeText(typeof value === "string"
|
|
43
|
+
? value
|
|
44
|
+
: value instanceof JsonNumber
|
|
45
|
+
? value.text
|
|
46
|
+
: dumpsJson(value));
|
|
47
|
+
}
|
|
48
|
+
const length = (text) => Array.from(text).length;
|
|
49
|
+
function clip(text, width) {
|
|
50
|
+
const characters = Array.from(text);
|
|
51
|
+
return characters.length <= width
|
|
52
|
+
? text
|
|
53
|
+
: `${characters.slice(0, Math.max(1, width) - 1).join("")}…`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Lines of an aligned table no wider than `width`: a header, then one line a
|
|
57
|
+
* row. Cells must already be escaped. A narrow terminal drops trailing columns
|
|
58
|
+
* and shortens the widest ones.
|
|
59
|
+
*/
|
|
60
|
+
export function tableLines(headers, rows, width, style) {
|
|
61
|
+
const total = Math.max(20, Math.floor(width));
|
|
62
|
+
const visible = headers.slice(0, Math.max(1, Math.floor((total + 2) / 12)));
|
|
63
|
+
const room = total - (visible.length - 1) * 2;
|
|
64
|
+
const share = Math.floor(room / visible.length);
|
|
65
|
+
const natural = visible.map((header, index) => Math.max(length(header), ...rows.map((row) => length(row[index] ?? ""))));
|
|
66
|
+
const widths = natural.map((size) => Math.min(size, share));
|
|
67
|
+
// Columns narrower than their share leave room for the ones that need more.
|
|
68
|
+
let spare = room - widths.reduce((sum, size) => sum + size, 0);
|
|
69
|
+
natural.forEach((size, index) => {
|
|
70
|
+
const extra = Math.min(spare, size - (widths[index] ?? size));
|
|
71
|
+
widths[index] = (widths[index] ?? size) + extra;
|
|
72
|
+
spare -= extra;
|
|
73
|
+
});
|
|
74
|
+
const line = (row) => visible
|
|
75
|
+
.map((_, index) => clip(row[index] ?? "", widths[index] ?? share).padEnd(widths[index] ?? share))
|
|
76
|
+
.join(" ")
|
|
77
|
+
.trimEnd();
|
|
78
|
+
return [style.bold(line(visible)), ...rows.map(line)];
|
|
79
|
+
}
|
|
80
|
+
const oneLine = (value) => isScalar(value) && !(typeof value === "string" && value.includes("\n"));
|
|
81
|
+
/**
|
|
82
|
+
* What goes under a key instead of beside it: the lines of a nested object, a
|
|
83
|
+
* list of objects or text with line breaks. Undefined for a shape this layout
|
|
84
|
+
* can't hold.
|
|
85
|
+
*/
|
|
86
|
+
function linesUnder(entry, indent, style, depth) {
|
|
87
|
+
if (typeof entry === "string")
|
|
88
|
+
// The bar is something a field never has, so a name with a line break
|
|
89
|
+
// in it cannot pass for one.
|
|
90
|
+
return entry
|
|
91
|
+
.replaceAll("\r\n", "\n")
|
|
92
|
+
.replace(/\n$/, "")
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map((line) => `${indent} ${style.dim("│")} ${safeText(line.replaceAll("\t", " "))}`);
|
|
95
|
+
const nested = jsonObject(entry);
|
|
96
|
+
if (nested)
|
|
97
|
+
return detailLines(nested, style, depth + 1);
|
|
98
|
+
if (!Array.isArray(entry))
|
|
99
|
+
return undefined;
|
|
100
|
+
const lines = [];
|
|
101
|
+
for (const item of entry) {
|
|
102
|
+
const object = jsonObject(item);
|
|
103
|
+
const below = object && detailLines(object, style, depth + 2);
|
|
104
|
+
if (!below)
|
|
105
|
+
return undefined;
|
|
106
|
+
// Each object starts at a dash, one level in from the key.
|
|
107
|
+
const [first = `${indent} {}`, ...rest] = below;
|
|
108
|
+
lines.push(`${indent} - ${first.slice(indent.length + 4)}`, ...rest);
|
|
109
|
+
}
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Lines of an aligned key/value view of one object, with nothing left out or
|
|
114
|
+
* shortened: a person may not be able to run the command again to see the
|
|
115
|
+
* rest. Nested objects, lists of objects and text with line breaks are
|
|
116
|
+
* indented under their key. Returns undefined for a shape this layout can't
|
|
117
|
+
* hold (lists of lists, mixed lists, deep nesting); the caller prints JSON.
|
|
118
|
+
*/
|
|
119
|
+
export function detailLines(value, style, depth = 0) {
|
|
120
|
+
if (depth > MAX_DEPTH)
|
|
121
|
+
return undefined;
|
|
122
|
+
const indent = " ".repeat(depth);
|
|
123
|
+
const entries = [];
|
|
124
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
125
|
+
const key = safeText(name);
|
|
126
|
+
if (oneLine(entry))
|
|
127
|
+
entries.push({ key, beside: entry === "" ? '""' : cell(entry) });
|
|
128
|
+
else if (Array.isArray(entry) && entry.every(oneLine))
|
|
129
|
+
entries.push({
|
|
130
|
+
key,
|
|
131
|
+
beside: entry.length ? entry.map(cell).join(", ") : "[]",
|
|
132
|
+
});
|
|
133
|
+
else {
|
|
134
|
+
const below = linesUnder(entry, indent, style, depth);
|
|
135
|
+
if (!below)
|
|
136
|
+
return undefined;
|
|
137
|
+
entries.push(below.length ? { key, below } : { key, beside: "{}" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// A very long key stands out of the column instead of widening it.
|
|
141
|
+
const width = Math.min(32, Math.max(0, ...entries.flatMap(({ key, below }) => (below ? [] : [length(key)]))));
|
|
142
|
+
return entries.flatMap(({ key, beside, below }) => below
|
|
143
|
+
? [`${indent}${style.dim(key)}`, ...below]
|
|
144
|
+
: [`${indent}${style.dim(key.padEnd(width))} ${beside}`]);
|
|
145
|
+
}
|
package/dist/rpc.js
CHANGED
|
@@ -154,7 +154,7 @@ export async function runRpc(context, options, loadedCatalog) {
|
|
|
154
154
|
});
|
|
155
155
|
if (options.preview)
|
|
156
156
|
return { kind: "preview", workspaceId: context.workspaceId, requestJson };
|
|
157
|
-
//
|
|
157
|
+
// A call may run for 210 seconds; leave time to receive its result.
|
|
158
158
|
const timeout = AbortSignal.timeout(240_000);
|
|
159
159
|
const signal = context.signal
|
|
160
160
|
? AbortSignal.any([context.signal, timeout])
|
package/dist/runner.d.ts
CHANGED
package/dist/runner.js
CHANGED
|
@@ -5,25 +5,10 @@ import { AuthClient } from "./auth.js";
|
|
|
5
5
|
import { commandOptions, parseGlobals } from "./argv.js";
|
|
6
6
|
import { authorizedWorkspace, contextStatus, useContext } from "./context.js";
|
|
7
7
|
import { CliError, record, safeText, throwIfInterrupted } from "./errors.js";
|
|
8
|
+
import { rootHelp } from "./help.js";
|
|
8
9
|
import { Profiles, selectedWorkspace } from "./profiles.js";
|
|
10
|
+
import { spinner, styleFor, terminalWidth } from "./style.js";
|
|
9
11
|
export { parseGlobals } from "./argv.js";
|
|
10
|
-
const help = `Zo public command line client
|
|
11
|
-
|
|
12
|
-
Usage: zo <command> [--profile <name>] [--workspace <id>] [--json]
|
|
13
|
-
|
|
14
|
-
login / logout Sign in through your browser or revoke the sign-in
|
|
15
|
-
auth status / token Inspect your identity or explicitly print an access token
|
|
16
|
-
context list/use/show Select an authorized workspace
|
|
17
|
-
actions list/describe Discover live action contracts
|
|
18
|
-
rpc <action> Invoke any callable action from JSON
|
|
19
|
-
<resource> <action> Invoke an installed built-in command
|
|
20
|
-
completion <shell> Print bash, zsh or fish completion (offline)
|
|
21
|
-
--version Show the installed CLI version
|
|
22
|
-
|
|
23
|
-
Run zo <command> --help for options; for example, zo machine list --help.
|
|
24
|
-
Use --profile to keep separate sign-ins. New profiles use production.
|
|
25
|
-
ZO_SYSTEM_API_ACCESS_TOKEN overrides saved credentials; ZO_WORKSPACE_ID selects a workspace.
|
|
26
|
-
`;
|
|
27
12
|
const localHelp = {
|
|
28
13
|
login: "Usage: zo login [--profile <name>] [--workspace <id>] [--device]\nOpen your browser to sign in and wait for approval.\nNew profiles use production. Browser consent approves workspaces and identities.\n--device prints a URL and code to approve on any device; login does the same when this machine can't open a browser.\nInternal testing: --environment staging explicitly binds a separate profile to staging.\n",
|
|
29
14
|
logout: "Usage: zo logout [--profile <name>]\nRevoke the saved sign-in and pending replaced sign-ins, then remove them from this computer. If revocation fails, logout says why and removes them anyway.\n",
|
|
@@ -39,12 +24,17 @@ const localHelp = {
|
|
|
39
24
|
"actions describe": "Usage: zo actions describe <action> [--version <integer>] [--catalog scoped|all] [--json]\nShow live input, output and query schemas. Omit --version to inspect every published version.\n",
|
|
40
25
|
rpc: "Usage: zo rpc <action> [--input <json> | --input-file <path|->] [--version <integer>]\n --query <json> Declared list-query options; one page per call\n --idempotency-key <key> Envelope key where the action supports it\n --preview Print the request without invoking\n --envelope Print the complete success envelope\n --json Print exact result JSON\n --profile <name> --workspace <id>\nThe live catalog supplies the default version. RPC preserves supplied inputs. Invocations are never retried.\n",
|
|
41
26
|
completion: "Usage: zo completion bash|zsh|fish\nPrint completion from installed metadata without credentials or network requests.\n",
|
|
27
|
+
update: "Usage: zo update [--json]\nDownload the latest standalone release, verify it and switch the zo command to it. Sign-ins are kept.\nDoes nothing when this version is already the latest. An npm installation upgrades with npm install --global @zocomputer/cli@latest.\n",
|
|
42
28
|
};
|
|
43
29
|
// Printed after login so a first-time caller, person or agent, knows the next
|
|
44
30
|
// command and that one sign-in serves the CLI, HTTP and MCP alike.
|
|
45
|
-
const nextSteps =
|
|
46
|
-
"
|
|
47
|
-
"
|
|
31
|
+
const nextSteps = (style) => `\n${style.bold("Next")}\n` +
|
|
32
|
+
" zo workspace get Make a first call\n" +
|
|
33
|
+
" zo actions list See what you can call\n" +
|
|
34
|
+
" zo auth token --headers Print an Authorization header for HTTP or MCP\n";
|
|
35
|
+
function subjectText({ type, id }) {
|
|
36
|
+
return `${type.replaceAll("_", " ")} ${safeText(id)}`;
|
|
37
|
+
}
|
|
48
38
|
function identity(session, workspace) {
|
|
49
39
|
const { type, id } = workspace.subject;
|
|
50
40
|
if (type === "user" &&
|
|
@@ -55,9 +45,11 @@ function identity(session, workspace) {
|
|
|
55
45
|
return `as service account ${safeText(id)}`;
|
|
56
46
|
return `as ${type} ${safeText(id)}`;
|
|
57
47
|
}
|
|
58
|
-
async function showHelp(args, stdout) {
|
|
48
|
+
async function showHelp(args, stdout, env) {
|
|
49
|
+
// Help is one text for people and agents; a terminal only adds emphasis.
|
|
50
|
+
const style = styleFor(stdout, env);
|
|
59
51
|
if (!args.length) {
|
|
60
|
-
stdout.write(
|
|
52
|
+
stdout.write(rootHelp(style, (await import("./commands.js")).resourceNames()));
|
|
61
53
|
return;
|
|
62
54
|
}
|
|
63
55
|
const path = args.slice(0, args.findIndex((arg) => arg.startsWith("--")) === -1
|
|
@@ -66,25 +58,25 @@ async function showHelp(args, stdout) {
|
|
|
66
58
|
const key = path
|
|
67
59
|
.slice(0, ["auth", "context", "actions"].includes(path[0] ?? "") ? 2 : 1)
|
|
68
60
|
.join(" ");
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
stdout.write((await import("./commands.js")).builtinHelp(path));
|
|
61
|
+
const text = Object.hasOwn(localHelp, key)
|
|
62
|
+
? (localHelp[key] ?? "")
|
|
63
|
+
: (await import("./commands.js")).builtinHelp(path);
|
|
64
|
+
stdout.write(text.replace(/^Usage:/, style.bold("Usage:")));
|
|
74
65
|
}
|
|
75
66
|
export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
76
67
|
const { args, options } = parseGlobals(argv);
|
|
77
68
|
const stdout = runtime.stdout ?? process.stdout;
|
|
78
69
|
const stderr = runtime.stderr ?? process.stderr;
|
|
70
|
+
const env = runtime.env ?? process.env;
|
|
79
71
|
const output = {
|
|
80
72
|
stdout,
|
|
81
73
|
stderr,
|
|
82
74
|
json: options.json,
|
|
83
75
|
envelope: options.envelope,
|
|
76
|
+
env,
|
|
84
77
|
};
|
|
85
|
-
const env = runtime.env ?? process.env;
|
|
86
78
|
if (options.help || !args.length) {
|
|
87
|
-
await showHelp(args, stdout);
|
|
79
|
+
await showHelp(args, stdout, env);
|
|
88
80
|
return;
|
|
89
81
|
}
|
|
90
82
|
if (args[0] === "--version") {
|
|
@@ -106,7 +98,7 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
106
98
|
stdout.write((await import("./commands.js")).completionScript(args[1]));
|
|
107
99
|
return;
|
|
108
100
|
}
|
|
109
|
-
const local = ["login", "logout", "auth", "context"].includes(args[0] ?? "");
|
|
101
|
+
const local = ["login", "logout", "auth", "context", "update"].includes(args[0] ?? "");
|
|
110
102
|
if (options.envelope && local)
|
|
111
103
|
throw new CliError("--envelope is only supported for action invocation.");
|
|
112
104
|
const controller = new AbortController();
|
|
@@ -117,12 +109,45 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
117
109
|
: controller.signal;
|
|
118
110
|
try {
|
|
119
111
|
throwIfInterrupted(signal);
|
|
112
|
+
// A built-in command that doesn't exist is reported before anything reads
|
|
113
|
+
// a profile, so the answer isn't "select a workspace".
|
|
114
|
+
const invocation = !local && args[0] !== "rpc" && args[0] !== "actions";
|
|
115
|
+
const command = invocation
|
|
116
|
+
? (await import("./commands.js")).matchBuiltinCommand(args)
|
|
117
|
+
: undefined;
|
|
118
|
+
if (invocation && !command)
|
|
119
|
+
throw (await import("./commands.js")).unknownCommand(args);
|
|
120
120
|
const profiles = runtime.profiles ?? new Profiles();
|
|
121
|
-
const { writeJson } = await import("./output.js");
|
|
121
|
+
const { toJson, writeJson } = await import("./output.js");
|
|
122
122
|
const writeLocalJson = (value) => {
|
|
123
123
|
throwIfInterrupted(signal);
|
|
124
124
|
writeJson(value, output);
|
|
125
125
|
};
|
|
126
|
+
// A local command's result is JSON for a script and `text` for a person
|
|
127
|
+
// at a terminal. A command that already reported on stderr passes none.
|
|
128
|
+
const human = stdout.isTTY === true && !options.json;
|
|
129
|
+
const render = human ? await import("./render.js") : undefined;
|
|
130
|
+
const page = styleFor(stdout, env);
|
|
131
|
+
const notice = styleFor(stderr, env);
|
|
132
|
+
const writeLocal = (value, text) => {
|
|
133
|
+
throwIfInterrupted(signal);
|
|
134
|
+
if (!human)
|
|
135
|
+
writeJson(value, output);
|
|
136
|
+
else if (text)
|
|
137
|
+
stdout.write(text);
|
|
138
|
+
};
|
|
139
|
+
if (args[0] === "update") {
|
|
140
|
+
commandOptions(args.slice(1), []);
|
|
141
|
+
const { update } = await import("./update.js");
|
|
142
|
+
writeLocal(await update({
|
|
143
|
+
version: runtime.version,
|
|
144
|
+
env,
|
|
145
|
+
fetch: runtime.fetch,
|
|
146
|
+
signal,
|
|
147
|
+
stderr,
|
|
148
|
+
}));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
126
151
|
if (args[0] === "login") {
|
|
127
152
|
const parsed = commandOptions(args.slice(1), ["environment"], ["device"]);
|
|
128
153
|
const requested = parsed.values.get("environment");
|
|
@@ -136,20 +161,32 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
136
161
|
fetch: runtime.fetch,
|
|
137
162
|
signal,
|
|
138
163
|
});
|
|
139
|
-
|
|
164
|
+
// The status lines are one wording for whoever reads stderr. A terminal
|
|
165
|
+
// adds color, the success mark and a spinner; anything else gets the
|
|
166
|
+
// same lines plain, with one static line for the wait.
|
|
167
|
+
let waiting = { stop() { } };
|
|
168
|
+
const login = await auth
|
|
169
|
+
.login({
|
|
140
170
|
device: parsed.flags.has("device"),
|
|
141
171
|
signal,
|
|
142
|
-
announce: (prompt) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
announce: (prompt) => {
|
|
173
|
+
stderr.write(prompt.flow === "browser"
|
|
174
|
+
? `Opening your browser to sign in.\nIf it doesn't open, visit this URL on this computer:\n ${notice.dim(prompt.url)}\n\n`
|
|
175
|
+
: `To sign in, visit this URL on any device:\n ${notice.dim(prompt.url)}\nCode: ${notice.bold(prompt.userCode)}. Approve only if your browser shows the same code.\n\n`);
|
|
176
|
+
waiting = spinner(stderr, "Waiting for approval… (Ctrl-C to cancel)", env);
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
// Approval, refusal, a timeout and Ctrl-C all end the wait.
|
|
180
|
+
.finally(() => waiting.stop());
|
|
181
|
+
stderr.write(`${notice.ok(`Approved. Sign-in saved for profile ${profile.name}.`)}\n`);
|
|
147
182
|
if (login.cleanupPending)
|
|
148
183
|
stderr.write("A previous sign-in could not be revoked. Cleanup remains saved for the next login; the current sign-in is active. You can revoke the previous sign-in in Authorized apps.\n");
|
|
149
184
|
const session = await auth.session();
|
|
150
185
|
// Always list the approved workspaces, so a sign-in someone else
|
|
151
186
|
// approved shows up as workspaces or identities you don't recognize.
|
|
152
|
-
session.workspaces.forEach((workspace, index) => stderr.write(
|
|
187
|
+
session.workspaces.forEach((workspace, index) => stderr.write(` ${index + 1}. ${safeText(workspace.name)} (${workspace.workspace_id}), ${identity(session, workspace)}\n`));
|
|
188
|
+
if (session.workspaces.length)
|
|
189
|
+
stderr.write(` ${notice.dim("Calls use each workspace's identity and its current permissions.")}\n`);
|
|
153
190
|
const desired = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
|
|
154
191
|
let chosen;
|
|
155
192
|
if (options.workspace !== undefined)
|
|
@@ -185,8 +222,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
185
222
|
}
|
|
186
223
|
if (!chosen) {
|
|
187
224
|
stderr.write("Select an authorized workspace with zo context use <workspace-id>.\n" +
|
|
188
|
-
nextSteps);
|
|
189
|
-
|
|
225
|
+
nextSteps(notice));
|
|
226
|
+
writeLocal({
|
|
190
227
|
profile: profile.name,
|
|
191
228
|
signed_in: true,
|
|
192
229
|
workspace_id: null,
|
|
@@ -195,8 +232,9 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
195
232
|
return;
|
|
196
233
|
}
|
|
197
234
|
await profiles.select(profile, chosen, signal);
|
|
198
|
-
|
|
199
|
-
|
|
235
|
+
const name = session.workspaces.find((workspace) => workspace.workspace_id === chosen)?.name;
|
|
236
|
+
stderr.write(`Using workspace ${safeText(name ?? "")} (${chosen}).\n${nextSteps(notice)}`);
|
|
237
|
+
writeLocal({ profile: profile.name, workspace_id: chosen });
|
|
200
238
|
return;
|
|
201
239
|
}
|
|
202
240
|
const profile = await profiles.resolve(options.profile);
|
|
@@ -211,8 +249,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
211
249
|
const { warning } = await auth.logout();
|
|
212
250
|
throwIfInterrupted(signal);
|
|
213
251
|
stderr.write(warning === undefined
|
|
214
|
-
? "Signed out; saved sign-ins revoked
|
|
215
|
-
:
|
|
252
|
+
? `${notice.ok("Signed out; saved sign-ins revoked.")}\n`
|
|
253
|
+
: `${notice.yellow("Warning:")} ${warning}\nSigned out of this computer.\n`);
|
|
216
254
|
return;
|
|
217
255
|
}
|
|
218
256
|
if (args[0] === "auth" && args[1] === "token") {
|
|
@@ -228,22 +266,39 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
228
266
|
if ((args[0] === "auth" && args[1] === "status") ||
|
|
229
267
|
(args[0] === "context" && args[1] === "show")) {
|
|
230
268
|
commandOptions(args.slice(2), []);
|
|
231
|
-
|
|
269
|
+
const status = await contextStatus(profile, auth, options.workspace, env);
|
|
270
|
+
const view = render?.detailLines(render.jsonObject(toJson(status)) ?? {}, page);
|
|
271
|
+
writeLocal(status, view && `${view.join("\n")}\n`);
|
|
232
272
|
return;
|
|
233
273
|
}
|
|
234
274
|
if (args[0] === "context" && args[1] === "list") {
|
|
235
275
|
commandOptions(args.slice(2), []);
|
|
236
|
-
|
|
276
|
+
const { workspaces } = await auth.session();
|
|
277
|
+
const selected = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
|
|
278
|
+
// The mark shares the first column: a column of its own is the first
|
|
279
|
+
// one a narrow terminal would give up.
|
|
280
|
+
const lines = render?.tableLines([" WORKSPACE_ID", "NAME", "IDENTITY"], workspaces.map((workspace) => [
|
|
281
|
+
`${workspace.workspace_id === selected ? "*" : " "} ${workspace.workspace_id}`,
|
|
282
|
+
safeText(workspace.name),
|
|
283
|
+
subjectText(workspace.subject),
|
|
284
|
+
]), terminalWidth(stdout), page);
|
|
285
|
+
lines?.push(!workspaces.length
|
|
286
|
+
? "No authorized workspaces. Run zo login to approve one."
|
|
287
|
+
: workspaces.some((workspace) => workspace.workspace_id === selected)
|
|
288
|
+
? page.dim("* The selected workspace.")
|
|
289
|
+
: "None of these is selected. Choose one with zo context use <workspace-id>.");
|
|
290
|
+
writeLocal(workspaces, lines && `${lines.join("\n")}\n`);
|
|
237
291
|
return;
|
|
238
292
|
}
|
|
239
293
|
if (args[0] === "context" && args[1] === "use") {
|
|
240
294
|
if (args.length !== 3 || !args[2])
|
|
241
295
|
throw new CliError("Usage: zo context use <workspace-id>");
|
|
242
|
-
|
|
296
|
+
const selected = await useContext(profiles, profile, auth, args[2], signal);
|
|
297
|
+
writeLocal(selected, `${page.ok(`Using workspace ${safeText(selected.name)} (${selected.workspace_id}) as ${subjectText(selected.subject)}.`)}\n`);
|
|
243
298
|
return;
|
|
244
299
|
}
|
|
245
300
|
if (local)
|
|
246
|
-
throw
|
|
301
|
+
throw (await import("./commands.js")).unknownCommand(args);
|
|
247
302
|
const context = {
|
|
248
303
|
workspaceId: selectedWorkspace(profile, options.workspace, env),
|
|
249
304
|
apiOrigin: auth.binding.api,
|
|
@@ -288,15 +343,27 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
288
343
|
const version = parsed.values.get("version");
|
|
289
344
|
const parsedVersion = version === undefined ? undefined : parseVersion(version);
|
|
290
345
|
const { catalog } = await loadCatalog(context, { catalog: mode });
|
|
346
|
+
if (render && !action) {
|
|
347
|
+
const lines = render.tableLines(["ACTION", "DEFAULT", "VERSIONS", "DESCRIPTION"], catalog.operations.map((operation) => [
|
|
348
|
+
safeText(operation.action),
|
|
349
|
+
String(operation.defaultVersion ?? "-"),
|
|
350
|
+
operation.versions.map((entry) => entry.version).join(", "),
|
|
351
|
+
safeText((operation.versions.find((entry) => entry.version === operation.defaultVersion) ?? operation.versions.at(-1))?.description ?? ""),
|
|
352
|
+
]), terminalWidth(stdout, 100), page);
|
|
353
|
+
if (!catalog.operations.length)
|
|
354
|
+
lines.push("No results.");
|
|
355
|
+
lines.push(page.dim(render.completeResultHint));
|
|
356
|
+
stdout.write(`${lines.join("\n")}\n`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
291
359
|
writeJson(action
|
|
292
360
|
? describeAction(catalog, action, parsedVersion)
|
|
293
361
|
: listActions(catalog), output);
|
|
294
362
|
return;
|
|
295
363
|
}
|
|
296
|
-
const {
|
|
297
|
-
const command = matchBuiltinCommand(args);
|
|
364
|
+
const { runBuiltin, unknownCommand } = await import("./commands.js");
|
|
298
365
|
if (!command)
|
|
299
|
-
throw
|
|
366
|
+
throw unknownCommand(args);
|
|
300
367
|
const result = await runBuiltin(context, command.action, command.args, runtime.stdin);
|
|
301
368
|
(await import("./presentation.js")).writeBuiltinResult(command.action, result, { ...output, workspaceId: context.workspaceId });
|
|
302
369
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "./errors.ts";
|
|
2
2
|
export declare function configDirectory(env?: NodeJS.ProcessEnv): string;
|
|
3
3
|
export declare function privateDirectory(path: string): Promise<void>;
|
|
4
|
-
export declare function readPrivate(path: string): Promise<unknown | undefined>;
|
|
4
|
+
export declare function readPrivate(path: string, maxBytes?: number): Promise<unknown | undefined>;
|
|
5
5
|
export declare class PrivateWriteDurabilityError extends CliError {
|
|
6
6
|
constructor();
|
|
7
7
|
}
|
package/dist/storage.js
CHANGED
|
@@ -48,7 +48,7 @@ async function checkedFile(path) {
|
|
|
48
48
|
}
|
|
49
49
|
return file;
|
|
50
50
|
}
|
|
51
|
-
export async function readPrivate(path) {
|
|
51
|
+
export async function readPrivate(path, maxBytes = 1_048_576) {
|
|
52
52
|
let file;
|
|
53
53
|
try {
|
|
54
54
|
file = await checkedFile(path);
|
|
@@ -59,7 +59,7 @@ export async function readPrivate(path) {
|
|
|
59
59
|
throw error;
|
|
60
60
|
}
|
|
61
61
|
try {
|
|
62
|
-
if ((await file.stat()).size >
|
|
62
|
+
if ((await file.stat()).size > maxBytes)
|
|
63
63
|
throw new CliError("Local configuration is too large.", 2, "invalid_local_data");
|
|
64
64
|
return JSON.parse(await file.readFile("utf8"));
|
|
65
65
|
}
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** What the CLI reads from an output stream; injected test streams have only this. */
|
|
2
|
+
export interface Terminal {
|
|
3
|
+
write(text: string): unknown;
|
|
4
|
+
isTTY?: boolean;
|
|
5
|
+
columns?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Style {
|
|
8
|
+
/** The stream is a terminal, so it gets the layout written for people. */
|
|
9
|
+
readonly human: boolean;
|
|
10
|
+
/** Escape sequences may be written: a terminal with NO_COLOR unset. */
|
|
11
|
+
readonly color: boolean;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
dim(text: string): string;
|
|
14
|
+
red(text: string): string;
|
|
15
|
+
green(text: string): string;
|
|
16
|
+
yellow(text: string): string;
|
|
17
|
+
cyan(text: string): string;
|
|
18
|
+
/** A line reporting success; a terminal marks it. */
|
|
19
|
+
ok(text: string): string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Styles for one stream. Text passed in must already be safe to print: these
|
|
23
|
+
* functions only add escape sequences around it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function styleFor(stream: Pick<Terminal, "isTTY"> | undefined, env?: NodeJS.ProcessEnv): Style;
|
|
26
|
+
export declare function terminalWidth(stream: Pick<Terminal, "columns"> | undefined, fallback?: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* Returns the cursor to the start of its line and blanks what was there (a
|
|
29
|
+
* spinner frame, the terminal's echoed ^C) without an escape sequence, so it
|
|
30
|
+
* also works where color is off.
|
|
31
|
+
*/
|
|
32
|
+
export declare function blankLine(stream: Pick<Terminal, "columns">): string;
|
|
33
|
+
/**
|
|
34
|
+
* Says the CLI is waiting. A terminal gets one animated line, erased when
|
|
35
|
+
* stopped. Anything else gets the text once on a line that stays: a log or
|
|
36
|
+
* an agent reading it has nothing to redraw.
|
|
37
|
+
*/
|
|
38
|
+
export declare function spinner(stream: Terminal, text: string, env?: NodeJS.ProcessEnv): {
|
|
39
|
+
stop(): void;
|
|
40
|
+
};
|
package/dist/style.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
/**
|
|
3
|
+
* Styles for one stream. Text passed in must already be safe to print: these
|
|
4
|
+
* functions only add escape sequences around it.
|
|
5
|
+
*/
|
|
6
|
+
export function styleFor(stream, env = process.env) {
|
|
7
|
+
const human = stream?.isTTY === true;
|
|
8
|
+
// https://no-color.org: any non-empty value turns color off.
|
|
9
|
+
const color = human && !env.NO_COLOR;
|
|
10
|
+
// styleText's own stream check throws on anything but a real stream and
|
|
11
|
+
// reads process.env, while the CLI is handed its streams and environment.
|
|
12
|
+
// The decision is made here and styleText only supplies the codes.
|
|
13
|
+
const paint = (format) => (text) => color ? styleText(format, text, { validateStream: false }) : text;
|
|
14
|
+
const green = paint("green");
|
|
15
|
+
return {
|
|
16
|
+
human,
|
|
17
|
+
color,
|
|
18
|
+
bold: paint("bold"),
|
|
19
|
+
dim: paint("dim"),
|
|
20
|
+
red: paint("red"),
|
|
21
|
+
green,
|
|
22
|
+
yellow: paint("yellow"),
|
|
23
|
+
cyan: paint("cyan"),
|
|
24
|
+
ok: (text) => (human ? `${green("✓")} ${text}` : text),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function terminalWidth(stream, fallback = 80) {
|
|
28
|
+
const columns = stream?.columns;
|
|
29
|
+
return typeof columns === "number" && columns > 0 ? columns : fallback;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Returns the cursor to the start of its line and blanks what was there (a
|
|
33
|
+
* spinner frame, the terminal's echoed ^C) without an escape sequence, so it
|
|
34
|
+
* also works where color is off.
|
|
35
|
+
*/
|
|
36
|
+
export function blankLine(stream) {
|
|
37
|
+
return `\r${" ".repeat(terminalWidth(stream) - 1)}\r`;
|
|
38
|
+
}
|
|
39
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
40
|
+
/**
|
|
41
|
+
* Says the CLI is waiting. A terminal gets one animated line, erased when
|
|
42
|
+
* stopped. Anything else gets the text once on a line that stays: a log or
|
|
43
|
+
* an agent reading it has nothing to redraw.
|
|
44
|
+
*/
|
|
45
|
+
export function spinner(stream, text, env = process.env) {
|
|
46
|
+
const style = styleFor(stream, env);
|
|
47
|
+
if (!style.human) {
|
|
48
|
+
stream.write(`${text}\n`);
|
|
49
|
+
return { stop() { } };
|
|
50
|
+
}
|
|
51
|
+
// A frame wider than the terminal wraps, and every redraw would then
|
|
52
|
+
// scroll it by a line.
|
|
53
|
+
const room = terminalWidth(stream) - 3;
|
|
54
|
+
const characters = Array.from(text);
|
|
55
|
+
const label = characters.length > room
|
|
56
|
+
? `${characters.slice(0, Math.max(0, room - 1)).join("")}…`
|
|
57
|
+
: text;
|
|
58
|
+
let frame = 0;
|
|
59
|
+
const draw = () => stream.write(`\r${style.cyan(frames[frame++ % frames.length] ?? "")} ${label}`);
|
|
60
|
+
draw();
|
|
61
|
+
const timer = setInterval(draw, 80);
|
|
62
|
+
// The wait it decorates keeps the process alive; the animation must not.
|
|
63
|
+
timer.unref();
|
|
64
|
+
let stopped = false;
|
|
65
|
+
return {
|
|
66
|
+
stop() {
|
|
67
|
+
if (stopped)
|
|
68
|
+
return;
|
|
69
|
+
stopped = true;
|
|
70
|
+
clearInterval(timer);
|
|
71
|
+
stream.write(blankLine(stream));
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|