@zumino/cli 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/bin/zumino.mjs +7 -0
- package/package.json +33 -0
- package/skill/SKILL.md +232 -0
- package/src/client.js +247 -0
- package/src/commands/api.js +50 -0
- package/src/commands/auth.js +345 -0
- package/src/commands/context.js +55 -0
- package/src/commands/epic.js +85 -0
- package/src/commands/find.js +83 -0
- package/src/commands/init.js +174 -0
- package/src/commands/queue.js +60 -0
- package/src/commands/request.js +176 -0
- package/src/commands/self-update.js +40 -0
- package/src/commands/skill.js +97 -0
- package/src/commands/task.js +223 -0
- package/src/config.js +373 -0
- package/src/errors.js +47 -0
- package/src/items.js +86 -0
- package/src/main.js +163 -0
- package/src/output.js +192 -0
package/src/items.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { api, resolveWorkspace } from "./client.js";
|
|
2
|
+
import { requireProject } from "./config.js";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { pick } from "./output.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Turning what a person typed into a path the API answers.
|
|
8
|
+
*
|
|
9
|
+
* A code (`ONS-14`) is a **lookup, not an address** — `docs/decisions/0004`. So
|
|
10
|
+
* every command that takes one resolves it once at
|
|
11
|
+
* `GET /workspaces/{workspace}/items/{code}` and then uses the `path` that read
|
|
12
|
+
* hands back. Nothing here builds an item path out of a kind and a number by
|
|
13
|
+
* hand: doing so would put the kind→segment mapping in a second place, and the
|
|
14
|
+
* first place is a registry the server holds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve `ONS-14`, or a bare number in the current project.
|
|
19
|
+
*
|
|
20
|
+
* @param {any} ctx
|
|
21
|
+
* @param {string} ref a code, `#14`, or `14`
|
|
22
|
+
* @param {{kind?: "task"|"request"|"epic"}} [opts] kind for the bare-number form
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveItem(ctx, ref, opts = {}) {
|
|
25
|
+
const workspace = await resolveWorkspace(ctx);
|
|
26
|
+
const bare = String(ref).replace(/^#/, "");
|
|
27
|
+
|
|
28
|
+
// A number in the current project, with the kind either stated by the caller
|
|
29
|
+
// or carried by a letter. `E6` is the form `zumino epic create` prints, and it
|
|
30
|
+
// resolved to nothing until now: an epic's real code is `ACME-E6`, so `E6`
|
|
31
|
+
// went to the code lookup and 404'd — the identifier the CLI handed back could
|
|
32
|
+
// not be fed to the next command. `docs/decisions/0004` addresses an item by
|
|
33
|
+
// kind and number, and that is what this is.
|
|
34
|
+
const local = /^([Ee])?(\d+)$/.exec(bare);
|
|
35
|
+
if (local) {
|
|
36
|
+
const project = requireProject(ctx);
|
|
37
|
+
const kind = local[1] ? "epic" : (opts.kind ?? "task");
|
|
38
|
+
if (opts.kind && kind !== opts.kind) {
|
|
39
|
+
throw new CliError(`${bare} names an ${kind}, not a ${opts.kind}.`, {
|
|
40
|
+
hint: `Use: zumino ${kind} … ${bare}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const number = local[2];
|
|
44
|
+
const segment = { task: "tasks", request: "requests", epic: "epics" }[kind];
|
|
45
|
+
const path = `/workspaces/${workspace}/projects/${project}/${segment}/${number}`;
|
|
46
|
+
return { path, number: Number(number), kind, project, workspace };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const item = pick(
|
|
50
|
+
await api(ctx, "GET", `/workspaces/${workspace}/items/${encodeURIComponent(bare)}`),
|
|
51
|
+
"item",
|
|
52
|
+
);
|
|
53
|
+
if (!item?.path) {
|
|
54
|
+
throw new CliError(`Could not resolve "${ref}".`, {
|
|
55
|
+
hint: "Codes look like ONS-14 or ONS-E3. A bare number, or E<n> for an epic, needs --project.",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// A code resolves to whatever kind the server says it is, and `done` is a
|
|
59
|
+
// valid status on more than one of them — so without this, `zumino task
|
|
60
|
+
// status ONS-E3 done` would close an *epic* through the task subcommand. The
|
|
61
|
+
// per-kind split exists precisely so a write names what it is writing to.
|
|
62
|
+
if (opts.kind && item.kind !== opts.kind) {
|
|
63
|
+
const article = (k) => (/^[aeiou]/.test(k) ? "an" : "a");
|
|
64
|
+
throw new CliError(
|
|
65
|
+
`${bare} is ${article(item.kind)} ${item.kind}, not ${article(opts.kind)} ${opts.kind}.`,
|
|
66
|
+
{
|
|
67
|
+
hint: `Use: zumino ${item.kind} … ${bare}`,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
path: item.path,
|
|
74
|
+
number: item.number,
|
|
75
|
+
kind: item.kind,
|
|
76
|
+
project: item.project?.slug,
|
|
77
|
+
workspace,
|
|
78
|
+
item,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The project path every per-kind write hangs off. */
|
|
83
|
+
export async function projectPath(ctx) {
|
|
84
|
+
const workspace = await resolveWorkspace(ctx);
|
|
85
|
+
return `/workspaces/${workspace}/projects/${requireProject(ctx)}`;
|
|
86
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
import { CliError, EXIT_FAILURE } from "./errors.js";
|
|
4
|
+
import { VERSION } from "./client.js";
|
|
5
|
+
import { bold, dim, note, out } from "./output.js";
|
|
6
|
+
|
|
7
|
+
import * as auth from "./commands/auth.js";
|
|
8
|
+
import * as queue from "./commands/queue.js";
|
|
9
|
+
import * as context from "./commands/context.js";
|
|
10
|
+
import * as find from "./commands/find.js";
|
|
11
|
+
import * as task from "./commands/task.js";
|
|
12
|
+
import * as request from "./commands/request.js";
|
|
13
|
+
import * as epic from "./commands/epic.js";
|
|
14
|
+
import * as raw from "./commands/api.js";
|
|
15
|
+
import * as init from "./commands/init.js";
|
|
16
|
+
import * as skill from "./commands/skill.js";
|
|
17
|
+
import * as selfUpdate from "./commands/self-update.js";
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Argument parsing and dispatch.
|
|
21
|
+
*
|
|
22
|
+
* The command shape is `DOMAIN.md`'s rule, not a style choice: **generic reads
|
|
23
|
+
* span every kind, and every write names its kind.** `queue`, `context` and
|
|
24
|
+
* `find` are top-level because they answer across kinds; everything that writes
|
|
25
|
+
* is `task …`, `request …` or `epic …`, so a fourth kind adds a noun rather
|
|
26
|
+
* than widening a flat verb list every script has already learned.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const OPTIONS = {
|
|
30
|
+
// global
|
|
31
|
+
json: { type: "boolean" },
|
|
32
|
+
host: { type: "string" },
|
|
33
|
+
token: { type: "string" },
|
|
34
|
+
project: { type: "string" },
|
|
35
|
+
workspace: { type: "string" },
|
|
36
|
+
account: { type: "string" },
|
|
37
|
+
help: { type: "boolean", short: "h" },
|
|
38
|
+
version: { type: "boolean" },
|
|
39
|
+
limit: { type: "string" },
|
|
40
|
+
// reads
|
|
41
|
+
"needs-input": { type: "boolean" },
|
|
42
|
+
kind: { type: "string" },
|
|
43
|
+
status: { type: "string" },
|
|
44
|
+
state: { type: "string" },
|
|
45
|
+
activity: { type: "boolean" },
|
|
46
|
+
// writes
|
|
47
|
+
title: { type: "string" },
|
|
48
|
+
description: { type: "string" },
|
|
49
|
+
assignee: { type: "string" },
|
|
50
|
+
epic: { type: "string" },
|
|
51
|
+
plan: { type: "string" },
|
|
52
|
+
acceptance: { type: "string" },
|
|
53
|
+
url: { type: "string" },
|
|
54
|
+
body: { type: "string" },
|
|
55
|
+
to: { type: "string" },
|
|
56
|
+
// housekeeping
|
|
57
|
+
dir: { type: "string" },
|
|
58
|
+
check: { type: "boolean" },
|
|
59
|
+
yes: { type: "boolean", short: "y" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const COMMANDS = {
|
|
63
|
+
queue: queue.run,
|
|
64
|
+
context: context.run,
|
|
65
|
+
find: find.run,
|
|
66
|
+
task: task.run,
|
|
67
|
+
request: request.run,
|
|
68
|
+
epic: epic.run,
|
|
69
|
+
api: raw.run,
|
|
70
|
+
auth: auth.run,
|
|
71
|
+
init: init.run,
|
|
72
|
+
skill: skill.run,
|
|
73
|
+
"self-update": selfUpdate.run,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const USAGE = `${bold("zumino")} — drive Zumino from a terminal, a script, or an agent.
|
|
77
|
+
|
|
78
|
+
${bold("Reads")} (across every kind of item)
|
|
79
|
+
zumino queue [--needs-input] [--limit N] what to work on next
|
|
80
|
+
zumino context <CODE> [--activity] the whole brief for one task
|
|
81
|
+
zumino find <query> [--kind request|task|epic] [--state open|closed]
|
|
82
|
+
|
|
83
|
+
${bold("Writes")} (each names its kind)
|
|
84
|
+
zumino task create --title T [--description D] [--epic N]
|
|
85
|
+
zumino task status <CODE> <status>
|
|
86
|
+
zumino task assign <CODE> <userId|->
|
|
87
|
+
zumino task spec <CODE> --plan TEXT | --acceptance TEXT
|
|
88
|
+
zumino task comment <CODE> <text>
|
|
89
|
+
zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE>
|
|
90
|
+
zumino task ref <CODE> --url URL [--title T]
|
|
91
|
+
zumino request answer <CODE|#N> <text>
|
|
92
|
+
zumino request promote <CODE|#N> --to <work-project>
|
|
93
|
+
zumino request status <CODE|#N> <status>
|
|
94
|
+
zumino request note <CODE|#N> <text>
|
|
95
|
+
zumino request comment <CODE|#N> <text>
|
|
96
|
+
zumino epic create --title T
|
|
97
|
+
zumino epic status <CODE> <status>
|
|
98
|
+
|
|
99
|
+
${bold("Everything else")}
|
|
100
|
+
zumino api <METHOD> <PATH> [--body JSON] any endpoint, including new ones
|
|
101
|
+
zumino auth login | status | list | logout
|
|
102
|
+
zumino init write .zumino.json for this repo
|
|
103
|
+
zumino skill install [--dir D] [--check] install the agent skill, globally
|
|
104
|
+
zumino self-update
|
|
105
|
+
|
|
106
|
+
${bold("Context")} is resolved in this order, and ${dim("zumino auth status")} says which won:
|
|
107
|
+
--token/--account → ZUMINO_TOKEN → .zumino.json → repo map → sole account
|
|
108
|
+
|
|
109
|
+
${bold("Flags")} --json --project P --workspace W --host URL --account NAME
|
|
110
|
+
${bold("Env")} ZUMINO_TOKEN ZUMINO_URL ZUMINO_PROJECT ZUMINO_WORKSPACE
|
|
111
|
+
ZUMINO_ACCOUNT ZUMINO_NO_UPDATE_CHECK
|
|
112
|
+
${bold("Exit")} 0 ok · 1 failed · 2 nothing resolved · 3 CLI too old`;
|
|
113
|
+
|
|
114
|
+
/** @param {string[]} argv */
|
|
115
|
+
export async function main(argv) {
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseArgs({
|
|
119
|
+
args: argv,
|
|
120
|
+
options: OPTIONS,
|
|
121
|
+
allowPositionals: true,
|
|
122
|
+
strict: true,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
note(`zumino: ${err.message}`);
|
|
126
|
+
note("Try: zumino --help");
|
|
127
|
+
return EXIT_FAILURE;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { values: flags, positionals } = parsed;
|
|
131
|
+
|
|
132
|
+
if (flags.version) {
|
|
133
|
+
out(VERSION);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const [name, ...rest] = positionals;
|
|
137
|
+
if (!name || name === "help" || (flags.help && !name)) {
|
|
138
|
+
out(USAGE);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const run = COMMANDS[name];
|
|
143
|
+
if (!run) {
|
|
144
|
+
note(`zumino: unknown command "${name}".`);
|
|
145
|
+
note("Try: zumino --help");
|
|
146
|
+
return EXIT_FAILURE;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return (await run(rest, flags)) ?? 0;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err instanceof CliError) {
|
|
153
|
+
note(`zumino: ${err.message}`);
|
|
154
|
+
// The hint is the actionable half, and it is why failures are phrased as
|
|
155
|
+
// imperatives with an exact command: an agent reads this on stderr and
|
|
156
|
+
// runs it, which is the whole mechanism by which drift gets fixed.
|
|
157
|
+
if (err.hint) note(` ${err.hint}`);
|
|
158
|
+
return err.exitCode;
|
|
159
|
+
}
|
|
160
|
+
note(`zumino: ${err?.message ?? String(err)}`);
|
|
161
|
+
return EXIT_FAILURE;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Everything the CLI prints, and the one rule that governs all of it:
|
|
6
|
+
*
|
|
7
|
+
* **stdout is the answer; stderr is everything else.** A caller pipes stdout
|
|
8
|
+
* into `jq`, so a version notice, a progress line or a warning written there
|
|
9
|
+
* would corrupt output that was otherwise machine-readable. There is no case in
|
|
10
|
+
* which a notice is worth breaking a pipe for.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const colorEnabled = () =>
|
|
14
|
+
process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
15
|
+
|
|
16
|
+
/** @param {string} s @param {Parameters<typeof styleText>[0]} style */
|
|
17
|
+
function paint(s, style) {
|
|
18
|
+
return colorEnabled() ? styleText(style, s) : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const dim = (s) => paint(s, "dim");
|
|
22
|
+
export const bold = (s) => paint(s, "bold");
|
|
23
|
+
export const amber = (s) => paint(s, "yellow");
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The writers do NOT sanitise, and that is deliberate — it was tried and it was
|
|
27
|
+
* wrong in both directions.
|
|
28
|
+
*
|
|
29
|
+
* `bold()` and `dim()` emit CSI sequences, and they run *before* the string
|
|
30
|
+
* reaches the sink, so a sanitising `out()` deleted the CLI's own styling:
|
|
31
|
+
* `--help` rendered plain and every dim hint disappeared. And `json()` goes
|
|
32
|
+
* through `out()` too, so it mutated the answer — `JSON.stringify` escapes C0 as
|
|
33
|
+
* `\uXXXX` but emits DEL and the C1 range raw, which the final rule then
|
|
34
|
+
* stripped from inside string literals, silently returning less than the API
|
|
35
|
+
* holds.
|
|
36
|
+
*
|
|
37
|
+
* The boundary is the untrusted **value**, not the assembled line. Everything
|
|
38
|
+
* read from a response or from a committed `.zumino.json` goes through
|
|
39
|
+
* `safeText` where it is interpolated — see the response fields in the command
|
|
40
|
+
* modules, and the refusal messages in `config.js` and `client.js`, which are
|
|
41
|
+
* the error path an earlier round found unguarded.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** A notice for a person, never part of the answer. Always stderr. */
|
|
45
|
+
export function note(s) {
|
|
46
|
+
process.stderr.write(s + "\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The answer. Always stdout. */
|
|
50
|
+
export function out(s) {
|
|
51
|
+
process.stdout.write(s + "\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function json(value) {
|
|
55
|
+
out(JSON.stringify(value, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A plain column table.
|
|
60
|
+
*
|
|
61
|
+
* Columns are padded to their widest cell, which is fine for the sizes a queue
|
|
62
|
+
* or a search answers with and avoids a dependency for the sake of box-drawing.
|
|
63
|
+
* `--json` exists for anything that wants to be parsed, so this is free to be
|
|
64
|
+
* for eyes only.
|
|
65
|
+
*
|
|
66
|
+
* @param {Array<Array<string>>} rows
|
|
67
|
+
* @param {{head?: string[]}} [opts]
|
|
68
|
+
*/
|
|
69
|
+
export function table(rows, opts = {}) {
|
|
70
|
+
const all = opts.head ? [opts.head, ...rows] : rows;
|
|
71
|
+
if (all.length === 0) return;
|
|
72
|
+
const widths = [];
|
|
73
|
+
for (const row of all) {
|
|
74
|
+
row.forEach((cell, i) => {
|
|
75
|
+
widths[i] = Math.max(widths[i] ?? 0, String(cell ?? "").length);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const render = (row) =>
|
|
79
|
+
row
|
|
80
|
+
.map((cell, i) =>
|
|
81
|
+
i === row.length - 1
|
|
82
|
+
? String(cell ?? "")
|
|
83
|
+
: String(cell ?? "").padEnd(widths[i]),
|
|
84
|
+
)
|
|
85
|
+
.join(" ")
|
|
86
|
+
.trimEnd();
|
|
87
|
+
if (opts.head) out(dim(render(opts.head)));
|
|
88
|
+
for (const row of rows) out(render(row));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Strip terminal control sequences from text the server sent.
|
|
93
|
+
*
|
|
94
|
+
* Item titles and bodies are written by other people — on a public feedback
|
|
95
|
+
* project, by anyone who can reach it — and they land on a terminal when
|
|
96
|
+
* somebody runs `zumino find` or `zumino task show`. Left intact, an ESC in a
|
|
97
|
+
* title is *interpreted*: CSI can clear the screen or forge the rest of the
|
|
98
|
+
* listing, and OSC 52 can replace the reader's clipboard. `JSON.parse` restores
|
|
99
|
+
* whatever escaping the transport applied, so the bytes arrive live.
|
|
100
|
+
*
|
|
101
|
+
* Everything printed from a response goes through here. Tabs and newlines are
|
|
102
|
+
* kept — they are legitimate in a description and in the context brief — and
|
|
103
|
+
* every other C0/C1 control, plus complete ANSI sequences, is removed.
|
|
104
|
+
*/
|
|
105
|
+
export function safeText(value) {
|
|
106
|
+
return String(value ?? "")
|
|
107
|
+
// CSI / OSC / other escape sequences, taken whole so no orphan tail is left
|
|
108
|
+
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")
|
|
109
|
+
.replace(/\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g, "")
|
|
110
|
+
.replace(/\u001B[@-Z\\-_]/g, "")
|
|
111
|
+
// remaining C0 (keeping \t \n \r) and C1
|
|
112
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Trim a title to keep a table one line per row on an ordinary terminal. */
|
|
116
|
+
export function clip(s, n = 60) {
|
|
117
|
+
const t = safeText(s).replace(/\s+/g, " ").trim();
|
|
118
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* An item's code, under whichever word the endpoint published it.
|
|
123
|
+
*
|
|
124
|
+
* The API says `code` on the generic spine read and `ref` on every task shape,
|
|
125
|
+
* for the same string — `tasks/the-api-publishes-an-item-code-under-two-words.md`.
|
|
126
|
+
* `DOMAIN.md` settles the word as **code**, so the CLI reads past the drift and
|
|
127
|
+
* always prints one. This is the only place that knows both spellings.
|
|
128
|
+
*/
|
|
129
|
+
export function codeOf(item) {
|
|
130
|
+
return item?.code ?? item?.ref ?? "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The object out of a response envelope.
|
|
135
|
+
*
|
|
136
|
+
* Every write on this API answers `{"task": {…}}`, `{"item": {…}}`,
|
|
137
|
+
* `{"comment": {…}}` and so on rather than the object alone — the envelope is
|
|
138
|
+
* what lets a response grow a second key without changing the shape of the
|
|
139
|
+
* first. The CLI unwraps at the edge so nothing downstream has to know which
|
|
140
|
+
* word a given endpoint chose, and falls back to the response itself so a bare
|
|
141
|
+
* body is not a crash.
|
|
142
|
+
*/
|
|
143
|
+
export function pick(res, key) {
|
|
144
|
+
return res?.[key] ?? res;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A workspace slug, however the endpoint published it.
|
|
149
|
+
*
|
|
150
|
+
* `GET /queue` sets `workspace` to a plain slug string; `GET /projects` sets it
|
|
151
|
+
* to a `{slug, name}` reference (`publicWorkspaceRefView`). Both feed paths that
|
|
152
|
+
* name their workspace, and interpolating the object yields
|
|
153
|
+
* `/workspaces/[object%20Object]/…`, which 404s — so every read of the field
|
|
154
|
+
* goes through here.
|
|
155
|
+
*/
|
|
156
|
+
export function workspaceSlug(value) {
|
|
157
|
+
return typeof value === "string" ? value : (value?.slug ?? null);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A flag that must be a positive integer, refused rather than coerced.
|
|
162
|
+
*
|
|
163
|
+
* `Number("foo")` is `NaN`, and `JSON.stringify({n: NaN})` is `{"n":null}` —
|
|
164
|
+
* which on `epicNumber` is the value that *detaches* the epic. So a typo in
|
|
165
|
+
* `--epic` silently did the opposite of what was asked. A bad `--limit` was
|
|
166
|
+
* milder (the server falls back to its default) but equally silent.
|
|
167
|
+
*/
|
|
168
|
+
export function positiveInt(value, flag) {
|
|
169
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
170
|
+
const n = Number(String(value).trim());
|
|
171
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
172
|
+
throw new CliError(`--${flag} takes a positive whole number, not "${value}".`);
|
|
173
|
+
}
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* An explicitly-given flag value, where empty means "clear it", not "absent".
|
|
179
|
+
*
|
|
180
|
+
* `parseArgs` reports `--description ""` and `--description=` as `""`, which is
|
|
181
|
+
* falsy — so a truthiness gate dropped the flag and the field was left untouched
|
|
182
|
+
* instead of cleared. `config.js` already states the rule for credentials ("an
|
|
183
|
+
* explicit empty value is an error, not an absence"); this is the same
|
|
184
|
+
* distinction for fields where empty is a legitimate instruction rather than a
|
|
185
|
+
* mistake.
|
|
186
|
+
*
|
|
187
|
+
* Returns `undefined` when the flag was not given at all, so a caller can tell
|
|
188
|
+
* the three cases apart.
|
|
189
|
+
*/
|
|
190
|
+
export function givenFlag(value) {
|
|
191
|
+
return value === undefined ? undefined : String(value);
|
|
192
|
+
}
|