@zocomputer/cli 0.4.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 +37 -0
- 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 -15
- package/dist/generated/commands.js +25 -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 +28 -2
- 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/runner.d.ts +0 -1
- package/dist/runner.js +105 -52
- 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/package.json +2 -2
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/runner.d.ts
CHANGED
package/dist/runner.js
CHANGED
|
@@ -5,26 +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
|
-
update Install the latest standalone release
|
|
22
|
-
--version Show the installed CLI version
|
|
23
|
-
|
|
24
|
-
Run zo <command> --help for options; for example, zo machine list --help.
|
|
25
|
-
Use --profile to keep separate sign-ins. New profiles use production.
|
|
26
|
-
ZO_SYSTEM_API_ACCESS_TOKEN overrides saved credentials; ZO_WORKSPACE_ID selects a workspace.
|
|
27
|
-
`;
|
|
28
12
|
const localHelp = {
|
|
29
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",
|
|
30
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",
|
|
@@ -44,9 +28,13 @@ const localHelp = {
|
|
|
44
28
|
};
|
|
45
29
|
// Printed after login so a first-time caller, person or agent, knows the next
|
|
46
30
|
// command and that one sign-in serves the CLI, HTTP and MCP alike.
|
|
47
|
-
const nextSteps =
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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
|
+
}
|
|
50
38
|
function identity(session, workspace) {
|
|
51
39
|
const { type, id } = workspace.subject;
|
|
52
40
|
if (type === "user" &&
|
|
@@ -57,9 +45,11 @@ function identity(session, workspace) {
|
|
|
57
45
|
return `as service account ${safeText(id)}`;
|
|
58
46
|
return `as ${type} ${safeText(id)}`;
|
|
59
47
|
}
|
|
60
|
-
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);
|
|
61
51
|
if (!args.length) {
|
|
62
|
-
stdout.write(
|
|
52
|
+
stdout.write(rootHelp(style, (await import("./commands.js")).resourceNames()));
|
|
63
53
|
return;
|
|
64
54
|
}
|
|
65
55
|
const path = args.slice(0, args.findIndex((arg) => arg.startsWith("--")) === -1
|
|
@@ -68,25 +58,25 @@ async function showHelp(args, stdout) {
|
|
|
68
58
|
const key = path
|
|
69
59
|
.slice(0, ["auth", "context", "actions"].includes(path[0] ?? "") ? 2 : 1)
|
|
70
60
|
.join(" ");
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
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:")));
|
|
76
65
|
}
|
|
77
66
|
export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
78
67
|
const { args, options } = parseGlobals(argv);
|
|
79
68
|
const stdout = runtime.stdout ?? process.stdout;
|
|
80
69
|
const stderr = runtime.stderr ?? process.stderr;
|
|
70
|
+
const env = runtime.env ?? process.env;
|
|
81
71
|
const output = {
|
|
82
72
|
stdout,
|
|
83
73
|
stderr,
|
|
84
74
|
json: options.json,
|
|
85
75
|
envelope: options.envelope,
|
|
76
|
+
env,
|
|
86
77
|
};
|
|
87
|
-
const env = runtime.env ?? process.env;
|
|
88
78
|
if (options.help || !args.length) {
|
|
89
|
-
await showHelp(args, stdout);
|
|
79
|
+
await showHelp(args, stdout, env);
|
|
90
80
|
return;
|
|
91
81
|
}
|
|
92
82
|
if (args[0] === "--version") {
|
|
@@ -119,16 +109,37 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
119
109
|
: controller.signal;
|
|
120
110
|
try {
|
|
121
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);
|
|
122
120
|
const profiles = runtime.profiles ?? new Profiles();
|
|
123
|
-
const { writeJson } = await import("./output.js");
|
|
121
|
+
const { toJson, writeJson } = await import("./output.js");
|
|
124
122
|
const writeLocalJson = (value) => {
|
|
125
123
|
throwIfInterrupted(signal);
|
|
126
124
|
writeJson(value, output);
|
|
127
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
|
+
};
|
|
128
139
|
if (args[0] === "update") {
|
|
129
140
|
commandOptions(args.slice(1), []);
|
|
130
141
|
const { update } = await import("./update.js");
|
|
131
|
-
|
|
142
|
+
writeLocal(await update({
|
|
132
143
|
version: runtime.version,
|
|
133
144
|
env,
|
|
134
145
|
fetch: runtime.fetch,
|
|
@@ -150,20 +161,32 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
150
161
|
fetch: runtime.fetch,
|
|
151
162
|
signal,
|
|
152
163
|
});
|
|
153
|
-
|
|
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({
|
|
154
170
|
device: parsed.flags.has("device"),
|
|
155
171
|
signal,
|
|
156
|
-
announce: (prompt) =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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`);
|
|
161
182
|
if (login.cleanupPending)
|
|
162
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");
|
|
163
184
|
const session = await auth.session();
|
|
164
185
|
// Always list the approved workspaces, so a sign-in someone else
|
|
165
186
|
// approved shows up as workspaces or identities you don't recognize.
|
|
166
|
-
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`);
|
|
167
190
|
const desired = options.workspace ?? env.ZO_WORKSPACE_ID ?? profile.workspaceId;
|
|
168
191
|
let chosen;
|
|
169
192
|
if (options.workspace !== undefined)
|
|
@@ -199,8 +222,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
199
222
|
}
|
|
200
223
|
if (!chosen) {
|
|
201
224
|
stderr.write("Select an authorized workspace with zo context use <workspace-id>.\n" +
|
|
202
|
-
nextSteps);
|
|
203
|
-
|
|
225
|
+
nextSteps(notice));
|
|
226
|
+
writeLocal({
|
|
204
227
|
profile: profile.name,
|
|
205
228
|
signed_in: true,
|
|
206
229
|
workspace_id: null,
|
|
@@ -209,8 +232,9 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
209
232
|
return;
|
|
210
233
|
}
|
|
211
234
|
await profiles.select(profile, chosen, signal);
|
|
212
|
-
|
|
213
|
-
|
|
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 });
|
|
214
238
|
return;
|
|
215
239
|
}
|
|
216
240
|
const profile = await profiles.resolve(options.profile);
|
|
@@ -225,8 +249,8 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
225
249
|
const { warning } = await auth.logout();
|
|
226
250
|
throwIfInterrupted(signal);
|
|
227
251
|
stderr.write(warning === undefined
|
|
228
|
-
? "Signed out; saved sign-ins revoked
|
|
229
|
-
:
|
|
252
|
+
? `${notice.ok("Signed out; saved sign-ins revoked.")}\n`
|
|
253
|
+
: `${notice.yellow("Warning:")} ${warning}\nSigned out of this computer.\n`);
|
|
230
254
|
return;
|
|
231
255
|
}
|
|
232
256
|
if (args[0] === "auth" && args[1] === "token") {
|
|
@@ -242,22 +266,39 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
242
266
|
if ((args[0] === "auth" && args[1] === "status") ||
|
|
243
267
|
(args[0] === "context" && args[1] === "show")) {
|
|
244
268
|
commandOptions(args.slice(2), []);
|
|
245
|
-
|
|
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`);
|
|
246
272
|
return;
|
|
247
273
|
}
|
|
248
274
|
if (args[0] === "context" && args[1] === "list") {
|
|
249
275
|
commandOptions(args.slice(2), []);
|
|
250
|
-
|
|
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`);
|
|
251
291
|
return;
|
|
252
292
|
}
|
|
253
293
|
if (args[0] === "context" && args[1] === "use") {
|
|
254
294
|
if (args.length !== 3 || !args[2])
|
|
255
295
|
throw new CliError("Usage: zo context use <workspace-id>");
|
|
256
|
-
|
|
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`);
|
|
257
298
|
return;
|
|
258
299
|
}
|
|
259
300
|
if (local)
|
|
260
|
-
throw
|
|
301
|
+
throw (await import("./commands.js")).unknownCommand(args);
|
|
261
302
|
const context = {
|
|
262
303
|
workspaceId: selectedWorkspace(profile, options.workspace, env),
|
|
263
304
|
apiOrigin: auth.binding.api,
|
|
@@ -302,15 +343,27 @@ export async function run(argv = process.argv.slice(2), runtime = {}) {
|
|
|
302
343
|
const version = parsed.values.get("version");
|
|
303
344
|
const parsedVersion = version === undefined ? undefined : parseVersion(version);
|
|
304
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
|
+
}
|
|
305
359
|
writeJson(action
|
|
306
360
|
? describeAction(catalog, action, parsedVersion)
|
|
307
361
|
: listActions(catalog), output);
|
|
308
362
|
return;
|
|
309
363
|
}
|
|
310
|
-
const {
|
|
311
|
-
const command = matchBuiltinCommand(args);
|
|
364
|
+
const { runBuiltin, unknownCommand } = await import("./commands.js");
|
|
312
365
|
if (!command)
|
|
313
|
-
throw
|
|
366
|
+
throw unknownCommand(args);
|
|
314
367
|
const result = await runBuiltin(context, command.action, command.args, runtime.stdin);
|
|
315
368
|
(await import("./presentation.js")).writeBuiltinResult(command.action, result, { ...output, workspaceId: context.workspaceId });
|
|
316
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
|
+
}
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Edits that turn one word into another: insert, delete, replace, or swap two neighbors. */
|
|
2
|
+
function editDistance(left, right) {
|
|
3
|
+
let previous = [];
|
|
4
|
+
let current = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
5
|
+
for (let row = 1; row <= left.length; row++) {
|
|
6
|
+
const next = [row];
|
|
7
|
+
for (let column = 1; column <= right.length; column++) {
|
|
8
|
+
const same = left[row - 1] === right[column - 1];
|
|
9
|
+
let cost = Math.min((current[column] ?? 0) + 1, (next[column - 1] ?? 0) + 1, (current[column - 1] ?? 0) + (same ? 0 : 1));
|
|
10
|
+
if (row > 1 &&
|
|
11
|
+
column > 1 &&
|
|
12
|
+
left[row - 1] === right[column - 2] &&
|
|
13
|
+
left[row - 2] === right[column - 1])
|
|
14
|
+
cost = Math.min(cost, (previous[column - 2] ?? 0) + 1);
|
|
15
|
+
next.push(cost);
|
|
16
|
+
}
|
|
17
|
+
previous = current;
|
|
18
|
+
current = next;
|
|
19
|
+
}
|
|
20
|
+
return current[right.length] ?? 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The candidate a mistyped word most likely meant: the nearest within one
|
|
24
|
+
* edit per three letters, else the shortest one the word begins.
|
|
25
|
+
*/
|
|
26
|
+
export function closest(word, candidates) {
|
|
27
|
+
const typed = word.toLowerCase();
|
|
28
|
+
let best;
|
|
29
|
+
let bestDistance = Math.max(1, Math.floor(typed.length / 3)) + 1;
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
const distance = editDistance(typed, candidate);
|
|
32
|
+
if (distance < bestDistance) {
|
|
33
|
+
best = candidate;
|
|
34
|
+
bestDistance = distance;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (best !== undefined || typed.length < 3)
|
|
38
|
+
return best;
|
|
39
|
+
return candidates
|
|
40
|
+
.filter((candidate) => candidate.startsWith(typed))
|
|
41
|
+
.sort((left, right) => left.length - right.length)[0];
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zocomputer/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Public Zo command line client",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"node": "^22.23.4 || ^24.22.1 || ^26.13.0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@zocomputer/system-api": "0.3.
|
|
17
|
+
"@zocomputer/system-api": "0.3.1"
|
|
18
18
|
},
|
|
19
19
|
"publishConfig": {
|
|
20
20
|
"access": "public",
|