@zumino/cli 2.1.0 → 2.2.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/src/flags.js ADDED
@@ -0,0 +1,257 @@
1
+ import { CliError } from "./errors.js";
2
+ import { flagsFor, valuesOf } from "./spec.js";
3
+
4
+ /*
5
+ * What the caller typed: checked against the spec, then coerced.
6
+ *
7
+ * `parseArgs` lexes every flag the CLI has, because it runs before the command
8
+ * is known. That is the whole reason this file exists: without it, `zumino find
9
+ * --priority high` parses perfectly, reaches the handler, is never read, and
10
+ * prints an unfiltered list that looks exactly like a filtered one. A flag the
11
+ * command in hand does not accept is a *refusal*, and so is a value outside the
12
+ * set the API will act on.
13
+ *
14
+ * Both checks read `spec.js`, which is also what the help pages are rendered
15
+ * from — so a flag cannot be documented and unvalidated, or validated and
16
+ * undocumented.
17
+ */
18
+
19
+ /**
20
+ * Refuse a flag this command does not take, or a value it cannot mean.
21
+ *
22
+ * @param {any} leaf the spec entry for the command being run
23
+ * @param {Record<string, any>} flags `parseArgs` values
24
+ * @param {string} path how the command is written, for the message
25
+ */
26
+ export function validateFlags(leaf, flags, path) {
27
+ const allowed = flagsFor(leaf);
28
+
29
+ for (const name of Object.keys(flags)) {
30
+ // `--version` and `--help` are answered before dispatch, on every command.
31
+ if (name === "version" || name === "help") continue;
32
+
33
+ // A flag that exists elsewhere in the CLI and is a plausible mistake here
34
+ // gets the explanation rather than the list. Declared per command, because
35
+ // what makes `--status` wrong on `find` is a property of that read.
36
+ const reject = leaf?.rejects?.[name];
37
+ if (reject) throw new CliError(reject.message, { hint: reject.hint });
38
+
39
+ const flag = allowed[name];
40
+ if (!flag) {
41
+ const offered = Object.entries(allowed)
42
+ .filter(([, f]) => !f.global)
43
+ .map(([n]) => `--${n}`);
44
+ throw new CliError(`${path} has no --${name}.`, {
45
+ hint: offered.length
46
+ ? `It takes: ${offered.join(" ")}. See: zumino help ${path}`
47
+ : `It takes no flags of its own. See: zumino help ${path}`,
48
+ });
49
+ }
50
+
51
+ // Checked here as well as where it is read, so `--limit 0` is refused
52
+ // before a project is resolved and a token is spent on a call that cannot
53
+ // succeed. The coercion at the call site is what produces the number.
54
+ if (flag.numeric === "positive") positiveInt(flags[name], name);
55
+ if (flag.numeric === "nonNegative") nonNegativeInt(flags[name], name);
56
+
57
+ // `--assignee someone@example.com` is a wrong *kind* of value, which is this
58
+ // layer's question — and answering it here means the message arrives before
59
+ // "No Zumino account is configured", which is what a caller sees first
60
+ // otherwise and is the less useful of the two.
61
+ if (flag.shape === "user") {
62
+ for (const given of listValues(flags, name) ?? []) {
63
+ assertUserRef(given, { flag: `--${name}`, allowClear: Boolean(flag.clearable) });
64
+ }
65
+ }
66
+
67
+ /*
68
+ * `--status ""` and `--status=` are *given* and carry nothing.
69
+ *
70
+ * Left alone they are the original defect in miniature: the value list comes
71
+ * out empty, no filter is sent, and the answer is an unfiltered list at exit
72
+ * 0. An empty value is only ever an instruction where the field can be
73
+ * cleared — `--size ""` on a patch — and never on a filter or a closed
74
+ * vocabulary.
75
+ */
76
+ if ((valuesOf(flag) || flag.shape) && !flag.clearable) {
77
+ const given = listValues(flags, name);
78
+ if (given && given.length === 0) {
79
+ throw new CliError(`--${name} was given no value.`, {
80
+ hint: `See: zumino help ${path}`,
81
+ });
82
+ }
83
+ }
84
+
85
+ const values = valuesOf(flag);
86
+ if (!values) continue;
87
+ for (const given of listValues(flags, name) ?? []) {
88
+ // A clearable field accepts `-` as "set this back to nothing", which is
89
+ // the same spelling `zumino task assign … -` uses.
90
+ if (flag.clearable && (given === "-" || given === "")) continue;
91
+ if (!values.includes(given)) {
92
+ throw new CliError(`--${name} takes ${values.join(", ")} — not "${given}".`, {
93
+ hint: `See: zumino help ${path}`,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Every value of a repeatable flag, flattened.
102
+ *
103
+ * `--status todo --status shaping` and `--status todo,shaping` are the same
104
+ * thing, because the API accepts both spellings of the same filter. Empty
105
+ * segments are dropped, so a trailing comma is not a value the server would
106
+ * silently ignore.
107
+ *
108
+ * @returns {string[]|undefined} `undefined` when the flag was not given
109
+ */
110
+ export function listValues(flags, name) {
111
+ const raw = flags[name];
112
+ if (raw === undefined) return undefined;
113
+ const all = (Array.isArray(raw) ? raw : [raw])
114
+ .flatMap((v) => String(v).split(","))
115
+ .map((v) => v.trim())
116
+ .filter((v) => v.length > 0);
117
+ return all;
118
+ }
119
+
120
+ /**
121
+ * The single value of a flag that is repeatable in the parser but not here.
122
+ *
123
+ * `--status` is a list on `task list` and one value on `task create`. Without
124
+ * this, the second `--status` would silently win — the same
125
+ * quietly-dropped-input failure this file exists to stop, one layer down.
126
+ *
127
+ * @returns {string|undefined}
128
+ */
129
+ export function oneValue(flags, name) {
130
+ const raw = flags[name];
131
+ if (raw === undefined) return undefined;
132
+ if (!Array.isArray(raw)) return String(raw);
133
+ if (raw.length > 1) {
134
+ throw new CliError(`--${name} takes one value here, and was given ${raw.length}.`);
135
+ }
136
+ return raw.length ? String(raw[0]) : undefined;
137
+ }
138
+
139
+ /**
140
+ * A flag that must be a positive integer, refused rather than coerced.
141
+ *
142
+ * `Number("foo")` is `NaN`, and `JSON.stringify({n: NaN})` is `{"n":null}` —
143
+ * which on `epicNumber` is the value that *detaches* the epic. So a typo in
144
+ * `--epic` silently did the opposite of what was asked. A bad `--limit` was
145
+ * milder (the server falls back to its default) but equally silent.
146
+ */
147
+ export function positiveInt(value, flag) {
148
+ return boundedInt(value, flag, 1);
149
+ }
150
+
151
+ /** Same, for `--offset`, where zero is the first page rather than an error. */
152
+ export function nonNegativeInt(value, flag) {
153
+ return boundedInt(value, flag, 0);
154
+ }
155
+
156
+ function boundedInt(value, flag, min) {
157
+ if (value === undefined || value === null || value === "") return undefined;
158
+ const raw = Array.isArray(value) ? value[value.length - 1] : value;
159
+ const n = Number(String(raw).trim());
160
+ if (!Number.isInteger(n) || n < min) {
161
+ throw new CliError(
162
+ `--${flag} takes a whole number${min === 1 ? " of 1 or more" : " of 0 or more"}, not "${raw}".`,
163
+ );
164
+ }
165
+ return n;
166
+ }
167
+
168
+ /**
169
+ * An explicitly-given flag value, where empty means "clear it", not "absent".
170
+ *
171
+ * `parseArgs` reports `--description ""` and `--description=` as `""`, which is
172
+ * falsy — so a truthiness gate dropped the flag and the field was left untouched
173
+ * instead of cleared. `config.js` already states the rule for credentials ("an
174
+ * explicit empty value is an error, not an absence"); this is the same
175
+ * distinction for fields where empty is a legitimate instruction rather than a
176
+ * mistake.
177
+ *
178
+ * Returns `undefined` when the flag was not given at all, so a caller can tell
179
+ * the three cases apart.
180
+ */
181
+ export function givenFlag(value) {
182
+ if (value === undefined) return undefined;
183
+ return Array.isArray(value) ? String(value[value.length - 1]) : String(value);
184
+ }
185
+
186
+ /**
187
+ * The shape of a value that names a person, checked without asking the server.
188
+ *
189
+ * The API takes a **user id** everywhere a person is named, which is right and
190
+ * also unusable by hand — so the two values somebody actually has are both wrong,
191
+ * and each is wrong misleadingly:
192
+ *
193
+ * - an **email** is looked up as an id and refused with "not a member of this
194
+ * workspace", which reads as "that person has no access" rather than "that is
195
+ * the wrong kind of value";
196
+ * - **`me`** is the one a script wants most and nothing accepted.
197
+ *
198
+ * This is the pure half: it classifies what was typed and refuses an email. It
199
+ * runs in `validateFlags` and, for `zumino task assign`, before the context is
200
+ * resolved at all — the shape of an argument is wrong whether or not a credential
201
+ * exists, and "No Zumino account is configured" is the less useful of the two
202
+ * answers. `resolveUser` in `users.js` is the half that makes the call.
203
+ *
204
+ * @param {string} value a user id, `me`, or `-`/`none`
205
+ * @param {{flag: string, allowClear?: boolean}} opts
206
+ * @returns {{kind: "clear"|"me"|"id", id?: string}}
207
+ */
208
+ export function assertUserRef(value, { flag, allowClear = false }) {
209
+ const v = String(value ?? "").trim();
210
+
211
+ if (v === "-" || v === "" || v === "none") {
212
+ if (allowClear) return { kind: "clear" };
213
+ throw new CliError(`--${flag} needs a person: a user id, or "me".`, {
214
+ hint: "A filter cannot ask for nobody — the API has no unassigned filter.",
215
+ });
216
+ }
217
+ if (v === "me") return { kind: "me" };
218
+ if (/@/.test(v)) {
219
+ throw new CliError(`"${v}" looks like an email; ${flag} takes a user id.`, {
220
+ hint:
221
+ "Find one on an item you can read: " +
222
+ "zumino task show <CODE> --json | jq -r '.assignee.id'",
223
+ });
224
+ }
225
+ return { kind: "id", id: v };
226
+ }
227
+
228
+ /**
229
+ * The comma-joined form the API's repeatable filters take.
230
+ *
231
+ * `?status=todo,shaping` rather than `?status=todo&status=shaping`: the server
232
+ * accepts either, and one parameter keeps the URL a person can read in a log.
233
+ */
234
+ export function csv(flags, name) {
235
+ const all = listValues(flags, name);
236
+ return all?.length ? all.join(",") : undefined;
237
+ }
238
+
239
+ /**
240
+ * The free text a list command searches for, given either way.
241
+ *
242
+ * `zumino find "rate limit"` and `zumino find --query "rate limit"` are the same
243
+ * call. Giving both is refused rather than resolved by precedence: one of the
244
+ * two was going to be dropped, and a search that quietly answers a different
245
+ * question than the one asked is the failure this whole module is about.
246
+ *
247
+ * @param {string[]} args positionals left after the subcommand
248
+ * @param {Record<string, any>} flags
249
+ */
250
+ export function searchText(args, flags) {
251
+ const positional = args.join(" ").trim();
252
+ const flagged = oneValue(flags, "query")?.trim();
253
+ if (positional && flagged !== undefined && flagged !== positional) {
254
+ throw new CliError("Give the search text once — as an argument or as --query, not both.");
255
+ }
256
+ return (flagged ?? positional) || undefined;
257
+ }
package/src/help.js ADDED
@@ -0,0 +1,259 @@
1
+ import {
2
+ COMMANDS,
3
+ FLAGS,
4
+ GLOBAL_FLAGS,
5
+ GROUPS,
6
+ command,
7
+ flagsFor,
8
+ leaves,
9
+ subcommand,
10
+ valuesOf,
11
+ } from "./spec.js";
12
+ import { bold, dim } from "./output.js";
13
+
14
+ /*
15
+ * Every page the CLI prints about itself, rendered from `spec.js`.
16
+ *
17
+ * Nothing here is written twice. A flag appears on a help page because the
18
+ * command declares it, and the validator refuses it because the command does
19
+ * not — the same line of the same file answers both, which is the only way a
20
+ * `--help` page stays true after the tenth flag is added.
21
+ *
22
+ * Three audiences, three shapes, one source:
23
+ *
24
+ * - `overview()` for somebody who typed `zumino` and wants to see the shape of
25
+ * it — every command, grouped, one line each.
26
+ * - `page()` for somebody who knows which command and needs the accepted
27
+ * values — the thing a flat usage string cannot hold.
28
+ * - `flat()` and `surface()` for a program. `surface()` is the declaration
29
+ * itself, so an agent can discover the vocabularies (`values.taskStatus`)
30
+ * rather than guessing them and reading a 400 back.
31
+ */
32
+
33
+ const WIDTH = 78;
34
+
35
+ /** Wrap prose to the terminal, indenting every line including the first. */
36
+ function wrap(text, indent = " ", width = WIDTH) {
37
+ const words = String(text).split(/\s+/).filter(Boolean);
38
+ const lines = [];
39
+ let line = "";
40
+ for (const word of words) {
41
+ if (line && (indent + line + " " + word).length > width) {
42
+ lines.push(indent + line);
43
+ line = word;
44
+ } else {
45
+ line = line ? `${line} ${word}` : word;
46
+ }
47
+ }
48
+ if (line) lines.push(indent + line);
49
+ return lines.join("\n");
50
+ }
51
+
52
+ const pad = (s, n) => s + " ".repeat(Math.max(1, n - s.length));
53
+
54
+ /** `--status STATUS`, as it is written on a page. */
55
+ function flagSyntax(name, flag) {
56
+ return flag.arg ? `--${name} ${flag.arg}` : `--${name}`;
57
+ }
58
+
59
+ /** The global flags, on one line, for the bottom of a command page. */
60
+ function globalLine() {
61
+ return GLOBAL_FLAGS.filter((n) => n !== "help")
62
+ .map((n) => flagSyntax(n, FLAGS[n]))
63
+ .join(" ");
64
+ }
65
+
66
+ /** `zumino --help`: the whole surface, one line per command. */
67
+ export function overview() {
68
+ const lines = [
69
+ `${bold("zumino")} — drive Zumino from a terminal, a script, or an agent.`,
70
+ ];
71
+
72
+ for (const [id, heading] of GROUPS) {
73
+ const group = COMMANDS.filter((c) => (c.group ?? "meta") === id);
74
+ if (group.length === 0) continue;
75
+ lines.push("", bold(heading));
76
+ for (const c of group) {
77
+ lines.push(` ${pad(c.name, 14)}${c.summary}`);
78
+ if (c.subcommands) {
79
+ lines.push(dim(` ${" ".repeat(14)}${c.subcommands.map((s) => s.name).join(" · ")}`));
80
+ }
81
+ }
82
+ }
83
+
84
+ lines.push(
85
+ "",
86
+ bold("One command in full"),
87
+ ` zumino help <command> [<subcommand>] ${dim("e.g. zumino help task list")}`,
88
+ ` zumino commands [--json] ${dim("every command at once, for a program")}`,
89
+ "",
90
+ bold("Context") +
91
+ ` is resolved in this order, and ${dim("zumino auth status")} says which won:`,
92
+ " --token/--account → ZUMINO_TOKEN → .zumino.json → repo map → sole stored account",
93
+ "",
94
+ `${bold("Global flags")} ${globalLine()}`,
95
+ `${bold("Env")} ZUMINO_TOKEN ZUMINO_URL ZUMINO_PROJECT ZUMINO_WORKSPACE`,
96
+ ` ZUMINO_ACCOUNT ZUMINO_NO_UPDATE_CHECK`,
97
+ `${bold("Exit")} 0 ok · 1 failed · 2 nothing resolved · 3 CLI too old`,
98
+ );
99
+ return lines.join("\n");
100
+ }
101
+
102
+ /** The page for one group — its subcommands and what each is for. */
103
+ function groupPage(group) {
104
+ const width = Math.max(...group.subcommands.map((s) => s.name.length)) + 2;
105
+ return [
106
+ `${bold(`zumino ${group.name}`)} — ${group.summary}`,
107
+ "",
108
+ ...group.subcommands.map((s) => ` ${pad(s.name, width)}${s.summary}`),
109
+ "",
110
+ dim(` zumino help ${group.name} <subcommand> for one in full`),
111
+ ].join("\n");
112
+ }
113
+
114
+ /**
115
+ * The page for one command: usage, arguments, every flag with the values it
116
+ * accepts, why it is shaped the way it is, and examples that run.
117
+ */
118
+ function leafPage(leaf, path) {
119
+ const lines = [`${bold(`zumino ${path}`)} — ${leaf.summary}`];
120
+
121
+ lines.push("", bold("Usage"), ` ${leaf.usage ?? `zumino ${path}`}`);
122
+
123
+ if (leaf.args?.length) {
124
+ const width = Math.max(...leaf.args.map(([a]) => a.length)) + 2;
125
+ lines.push("", bold("Arguments"));
126
+ for (const [arg, help] of leaf.args) lines.push(` ${pad(arg, width)}${help}`);
127
+ }
128
+
129
+ const own = Object.entries(flagsFor(leaf)).filter(([, f]) => !f.global);
130
+ if (own.length) {
131
+ const width = Math.max(...own.map(([n, f]) => flagSyntax(n, f).length)) + 2;
132
+ lines.push("", bold("Flags"));
133
+ for (const [name, flag] of own) {
134
+ lines.push(` ${pad(flagSyntax(name, flag), width)}${flag.help ?? ""}`);
135
+ const values = valuesOf(flag);
136
+ if (values) {
137
+ const listed = flag.clearable ? [...values, "-"] : values;
138
+ lines.push(dim(` ${" ".repeat(width)}${listed.join(" · ")}`));
139
+ }
140
+ }
141
+ }
142
+ lines.push("", dim(` Global: ${globalLine()}`));
143
+
144
+ if (leaf.notes) lines.push("", bold("Notes"), wrap(leaf.notes));
145
+
146
+ if (leaf.examples?.length) {
147
+ lines.push("", bold("Examples"));
148
+ for (const ex of leaf.examples) lines.push(` ${ex}`);
149
+ }
150
+
151
+ return lines.join("\n");
152
+ }
153
+
154
+ /**
155
+ * The most specific page for what was typed.
156
+ *
157
+ * Returns `null` when the name is not a command, so the caller can say so with
158
+ * a suggestion rather than printing a page for something else.
159
+ *
160
+ * @param {string[]} words e.g. `["task", "list"]`
161
+ */
162
+ export function page(words) {
163
+ const [name, sub] = words;
164
+ if (!name) return overview();
165
+ const top = command(name);
166
+ if (!top) return null;
167
+ if (!top.subcommands) return leafPage(top, top.name);
168
+ if (!sub) return groupPage(top);
169
+ const leaf = subcommand(top, sub);
170
+ if (!leaf) return null;
171
+ return leafPage(leaf, `${top.name} ${sub}`);
172
+ }
173
+
174
+ /**
175
+ * What to suggest when a name is not a command.
176
+ *
177
+ * The plural is the guess people and agents actually make — `zumino projects`
178
+ * for `zumino project list` — so it is answered with the command rather than
179
+ * with the whole help page.
180
+ */
181
+ export function suggest(name) {
182
+ const all = leaves();
183
+ const bare = String(name ?? "").toLowerCase();
184
+ const singular = bare.replace(/s$/, "");
185
+
186
+ if (bare === "whoami" || bare === "me") return "zumino auth status";
187
+ if (bare === "login" || bare === "logout") return `zumino auth ${bare}`;
188
+ if (bare === "ls" || bare === "list" || bare === "search") return "zumino find";
189
+ if (bare === "show" || bare === "get" || bare === "view") return "zumino task show <CODE>";
190
+
191
+ // A group whose list is what was probably meant: `projects` → `project list`.
192
+ const group = COMMANDS.find((c) => c.name === singular && c.subcommands);
193
+ if (group && subcommand(group, "list")) return `zumino ${group.name} list`;
194
+
195
+ const near = all.find((l) => l.path.startsWith(bare) || l.name === singular);
196
+ return near ? `zumino ${near.path}` : null;
197
+ }
198
+
199
+ /** `zumino commands`: every leaf, one line each, for grepping. */
200
+ export function flat() {
201
+ const all = leaves();
202
+ const width = Math.max(...all.map((l) => l.path.length)) + 2;
203
+ return all.map((l) => `${pad(l.path, width)}${l.summary}`).join("\n");
204
+ }
205
+
206
+ /**
207
+ * `zumino commands --json`: the declaration itself.
208
+ *
209
+ * The shape a program should read before driving this CLI. `values` is the point
210
+ * of it — the accepted set of every enumerated filter, so a caller does not
211
+ * discover `in_review` by having `--status review` refused.
212
+ *
213
+ * @param {{version: string, values: Record<string, string[]>}} opts
214
+ */
215
+ export function surface({ version, values }) {
216
+ return {
217
+ cli: version,
218
+ values,
219
+ global: GLOBAL_FLAGS.filter((n) => n !== "help").map((name) => ({
220
+ name,
221
+ arg: FLAGS[name].arg ?? null,
222
+ type: FLAGS[name].type,
223
+ help: FLAGS[name].help,
224
+ })),
225
+ env: [
226
+ "ZUMINO_TOKEN",
227
+ "ZUMINO_URL",
228
+ "ZUMINO_PROJECT",
229
+ "ZUMINO_WORKSPACE",
230
+ "ZUMINO_ACCOUNT",
231
+ "ZUMINO_NO_UPDATE_CHECK",
232
+ ],
233
+ exitCodes: {
234
+ 0: "ok",
235
+ 1: "failed",
236
+ 2: "nothing resolved — no token, project or workspace",
237
+ 3: "this CLI is too old for the server; run zumino self-update",
238
+ },
239
+ commands: leaves().map((leaf) => ({
240
+ path: leaf.path,
241
+ summary: leaf.summary,
242
+ usage: leaf.usage ?? `zumino ${leaf.path}`,
243
+ args: (leaf.args ?? []).map(([name, help]) => ({ name, help })),
244
+ flags: Object.entries(flagsFor(leaf))
245
+ .filter(([, f]) => !f.global)
246
+ .map(([name, f]) => ({
247
+ name,
248
+ arg: f.arg ?? null,
249
+ type: f.type,
250
+ repeatable: Boolean(f.multiple),
251
+ values: valuesOf(f),
252
+ clearable: Boolean(f.clearable),
253
+ help: f.help ?? null,
254
+ })),
255
+ notes: leaf.notes ?? null,
256
+ examples: leaf.examples ?? [],
257
+ })),
258
+ };
259
+ }