@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.
@@ -0,0 +1,223 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { codeOf, givenFlag, json, out, pick, positiveInt, safeText } from "../output.js";
5
+ import { projectPath, resolveItem } from "../items.js";
6
+
7
+ /*
8
+ * Everything that writes a task.
9
+ *
10
+ * Per-kind because `DOMAIN.md` says writes are per-kind: each has its own Zod
11
+ * schema and its own rate-limit bucket, so a run filing twenty-five tasks is
12
+ * never the reason naming the epic they belong to is refused.
13
+ */
14
+
15
+ const SUB = { create, status, assign, spec, comment, link, ref, attention, show };
16
+
17
+ export async function run(args, flags) {
18
+ const [sub, ...rest] = args;
19
+ const fn = SUB[sub];
20
+ if (!fn) {
21
+ throw new CliError(`zumino task: unknown subcommand "${sub ?? ""}".`, {
22
+ hint: `One of: ${Object.keys(SUB).join(", ")}`,
23
+ });
24
+ }
25
+ return fn(rest, flags);
26
+ }
27
+
28
+ async function create(args, flags) {
29
+ const ctx = resolveContext(flags);
30
+ const title = flags.title ?? args.join(" ").trim();
31
+ if (!title) {
32
+ throw new CliError("A task needs a title.", {
33
+ hint: 'zumino task create --title "…"',
34
+ });
35
+ }
36
+ const base = await projectPath(ctx);
37
+ const body = { title };
38
+ // `!== undefined`, not truthiness: `--description ""` is an instruction to
39
+ // leave it blank, and dropping it silently left whatever was there.
40
+ if (givenFlag(flags.description) !== undefined) body.description = flags.description;
41
+ if (givenFlag(flags.status) !== undefined) body.status = flags.status;
42
+ // An **epic** reference in this project, and nothing else.
43
+ //
44
+ // `epicNumber` is project-local, so a key prefix cannot express a foreign
45
+ // epic — its only effect was to let one through silently: `OPS-E14` targeted
46
+ // epic 14 *here*. And stripping leading letters off anything turned the task
47
+ // code `ONS-14` into epic 14, which is legal because task and epic numbers are
48
+ // separate namespaces, so the task was created under the wrong parent and
49
+ // reported success. Accept `E3` or `3`; refuse everything else and say why.
50
+ if (givenFlag(flags.epic) !== undefined) {
51
+ const raw = String(flags.epic).trim();
52
+ const m = /^[Ee]?(\d+)$/.exec(raw);
53
+ if (!m) {
54
+ throw new CliError(`--epic "${raw}" is not an epic in this project.`, {
55
+ hint:
56
+ "Write E3 or 3. A task code like ONS-14 is not an epic, and an epic in " +
57
+ "another project cannot be a parent — move the task instead.",
58
+ });
59
+ }
60
+ body.epicNumber = positiveInt(m[1], "epic");
61
+ }
62
+
63
+ const res = pick(await api(ctx, "POST", `${base}/tasks`, { body }), "task");
64
+ if (flags.json) return json(res), 0;
65
+ out(`${codeOf(res)} ${safeText(res.title)}`);
66
+ return 0;
67
+ }
68
+
69
+ async function status(args, flags) {
70
+ const [ref, value] = args;
71
+ if (!ref || !value) {
72
+ throw new CliError("zumino task status <CODE> <status>", {
73
+ hint: "Statuses are tokens: backlog, shaping, todo, in_progress, done, wont_do.",
74
+ });
75
+ }
76
+ const ctx = resolveContext(flags);
77
+ const { path } = await resolveItem(ctx, ref, { kind: "task" });
78
+ const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "task");
79
+ if (flags.json) return json(res), 0;
80
+ out(`${codeOf(res)} ${res.status}`);
81
+ return 0;
82
+ }
83
+
84
+ async function assign(args, flags) {
85
+ const [ref, who] = args;
86
+ if (!ref || !who) {
87
+ throw new CliError("zumino task assign <CODE> <userId|->", {
88
+ hint: "`-` clears the assignee. An assignee is always a person, even when an agent does the work.",
89
+ });
90
+ }
91
+ // An email is the natural guess and the server cannot say so: it looks the
92
+ // value up as a user id and refuses with "not a member of this workspace",
93
+ // which reads as "that person has no access" rather than "that is the wrong
94
+ // kind of value". Caught here, where the difference is still visible.
95
+ if (who !== "-" && /@/.test(who)) {
96
+ throw new CliError(`"${who}" looks like an email; assign takes a user id.`, {
97
+ hint: "Find one on an item you can read: zumino task show <CODE> --json | jq -r '.assignee.id'",
98
+ });
99
+ }
100
+
101
+ const ctx = resolveContext(flags);
102
+ const { path } = await resolveItem(ctx, ref, { kind: "task" });
103
+ const res = pick(
104
+ await api(ctx, "PATCH", path, { body: { assigneeId: who === "-" ? null : who } }),
105
+ "task",
106
+ );
107
+ if (flags.json) return json(res), 0;
108
+ out(`${codeOf(res)} ${safeText(res.assignee?.name) || "unassigned"}`);
109
+ return 0;
110
+ }
111
+
112
+ /**
113
+ * The two spec sections are written one at a time, and that is deliberate:
114
+ * an agent redrafting the plan must not be able to touch the criteria it will
115
+ * be judged against.
116
+ */
117
+ async function spec(args, flags) {
118
+ const [ref] = args;
119
+ const section = flags.plan !== undefined ? "plan" : flags.acceptance !== undefined ? "acceptance" : null;
120
+ if (!ref || !section) {
121
+ throw new CliError("zumino task spec <CODE> --plan TEXT | --acceptance TEXT", {
122
+ hint: "One section per call, on purpose.",
123
+ });
124
+ }
125
+ if (flags.plan !== undefined && flags.acceptance !== undefined) {
126
+ throw new CliError("Write one section at a time.", {
127
+ hint: "The plan and the criteria are written separately so redrafting one cannot touch the other.",
128
+ });
129
+ }
130
+ const ctx = resolveContext(flags);
131
+ const { path } = await resolveItem(ctx, ref, { kind: "task" });
132
+ const body = section === "plan" ? flags.plan : flags.acceptance;
133
+ const res = pick(
134
+ await api(ctx, "PUT", `${path}/spec/${section}`, { body: { body: body === "" ? null : body } }),
135
+ "task",
136
+ );
137
+ if (flags.json) return json(res), 0;
138
+ out(`${ref} ${section} written`);
139
+ return 0;
140
+ }
141
+
142
+ async function comment(args, flags) {
143
+ const [ref, ...text] = args;
144
+ const bodyText = flags.body ?? text.join(" ").trim();
145
+ if (!ref || !bodyText) throw new CliError("zumino task comment <CODE> <text>");
146
+ const ctx = resolveContext(flags);
147
+ const { path } = await resolveItem(ctx, ref, { kind: "task" });
148
+ const res = pick(await api(ctx, "POST", `${path}/comments`, { body: { body: bodyText } }), "comment");
149
+ if (flags.json) return json(res), 0;
150
+ out(`${ref} commented`);
151
+ return 0;
152
+ }
153
+
154
+ const LINK_TYPES = { blocks: "blocks", "blocked-by": "blockedBy", blockedby: "blockedBy", related: "related", answers: "answers" };
155
+
156
+ async function link(args, flags) {
157
+ const [ref, kind, other] = args;
158
+ const type = LINK_TYPES[String(kind ?? "").toLowerCase()];
159
+ if (!ref || !type || !other) {
160
+ throw new CliError("zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>", {
161
+ hint: "`answers` says this work exists because of a request, and takes project#N.",
162
+ });
163
+ }
164
+ const ctx = resolveContext(flags);
165
+ const { path } = await resolveItem(ctx, ref, { kind: "task" });
166
+ // A far end written `project#42` is a request; anything else is a task. The
167
+ // API takes them under different keys because only a request can be answered.
168
+ const body = other.includes("#") && !/^#?\d+$/.test(other)
169
+ ? { type, request: other }
170
+ : { type, task: other };
171
+ const res = pick(await api(ctx, "POST", `${path}/links`, { body }), "task");
172
+ if (flags.json) return json(res), 0;
173
+ out(`${ref} ${type} ${other}`);
174
+ return 0;
175
+ }
176
+
177
+ async function ref(args, flags) {
178
+ const [itemRef] = args;
179
+ const url = flags.url ?? args[1];
180
+ if (!itemRef || !url) {
181
+ throw new CliError("zumino task ref <CODE> --url URL [--title T]", {
182
+ hint: "A ref is the pull request that answers the task — a URL and a title, nothing more.",
183
+ });
184
+ }
185
+ const ctx = resolveContext(flags);
186
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
187
+ const body = { url };
188
+ if (givenFlag(flags.title) !== undefined) body.title = flags.title;
189
+ const res = pick(await api(ctx, "POST", `${path}/refs`, { body }), "ref");
190
+ if (flags.json) return json(res), 0;
191
+ out(`${itemRef} ${url}`);
192
+ return 0;
193
+ }
194
+
195
+ async function attention(args, flags) {
196
+ const [itemRef, ...text] = args;
197
+ if (!itemRef) throw new CliError("zumino task attention <CODE> [reason|-]");
198
+ const ctx = resolveContext(flags);
199
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
200
+ const reason = text.join(" ").trim();
201
+ const res = pick(
202
+ await api(ctx, "POST", `${path}/attention`, {
203
+ body: { reason: !reason || reason === "-" ? null : reason },
204
+ }),
205
+ "task",
206
+ );
207
+ if (flags.json) return json(res), 0;
208
+ out(`${itemRef} ${reason && reason !== "-" ? "needs input" : "cleared"}`);
209
+ return 0;
210
+ }
211
+
212
+ async function show(args, flags) {
213
+ const [itemRef] = args;
214
+ if (!itemRef) throw new CliError("zumino task show <CODE>");
215
+ const ctx = resolveContext(flags);
216
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
217
+ const res = pick(await api(ctx, "GET", path), "task");
218
+ if (flags.json) return json(res), 0;
219
+ out(`${codeOf(res)} ${safeText(res.title)}`);
220
+ out(`status ${res.status} priority ${res.priority ?? "-"} assignee ${safeText(res.assignee?.name) || "-"}`);
221
+ if (res.description) out(`\n${safeText(res.description)}`);
222
+ return 0;
223
+ }
package/src/config.js ADDED
@@ -0,0 +1,373 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, parse as parsePath, resolve } from "node:path";
5
+
6
+ import { CliError, EXIT_UNRESOLVED } from "./errors.js";
7
+ import { safeText } from "./output.js";
8
+
9
+ /**
10
+ * A slug that is safe to interpolate into a request path.
11
+ *
12
+ * `workspace` and `project` can come from a committed `.zumino.json`, and they
13
+ * are interpolated into every workspace-scoped path. Unvalidated, a value like
14
+ * `x/../../workspaces/acme/projects/feedback/requests/99?ignored=` is resolved
15
+ * by `new URL()` — dot segments collapse and `?` starts a query — so the fixed
16
+ * suffix the command appended became query text and the write landed on a
17
+ * different item, while the CLI printed the identifier the user asked for.
18
+ *
19
+ * The API's own slugs are lowercase alphanumeric with hyphens, and a key is the
20
+ * uppercase project prefix, so anything outside that is refused rather than
21
+ * escaped: encoding would make a hostile value merely fail, and saying why is
22
+ * more useful than a 404.
23
+ */
24
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
25
+
26
+ export function requireSafeSegment(value, what, origin) {
27
+ if (value === null || value === undefined) return value;
28
+ const v = String(value);
29
+ if (!SAFE_SEGMENT.test(v)) {
30
+ throw new CliError(
31
+ // The value is the reason this threw — it contains bytes outside the
32
+ // grammar — so it is exactly what must not reach a terminal live.
33
+ `${what} "${safeText(v)}" is not a valid slug.`,
34
+ {
35
+ exitCode: EXIT_UNRESOLVED,
36
+ hint: origin
37
+ ? `It came from ${safeText(origin)}. A slug is letters, digits, dots, dashes and underscores.`
38
+ : "A slug is letters, digits, dots, dashes and underscores.",
39
+ },
40
+ );
41
+ }
42
+ return v;
43
+ }
44
+
45
+ /*
46
+ * Where an account, a host and a project come from.
47
+ *
48
+ * The rule and the reasoning are `docs/decisions/0006-an-identity-is-discovered-
49
+ * not-current.md`, and the short version is the thing this file must not grow:
50
+ * **there is no stored current account and no `switch` command.** One person
51
+ * holds several accounts across several hosts, and two agents run in two
52
+ * checkouts at once — a single mutable "active" pointer in the home directory
53
+ * would let either of them silently change the other's identity, and a tracker
54
+ * write landing as the wrong person against the wrong host looks exactly like
55
+ * success.
56
+ *
57
+ * So resolution reads only two things: the process's own environment, and the
58
+ * directory it is standing in. Neither is shared between concurrent agents.
59
+ */
60
+
61
+ /** ~/.config/zumino/config.json, honouring XDG_CONFIG_HOME. */
62
+ export function configPath() {
63
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
64
+ return join(base, "zumino", "config.json");
65
+ }
66
+
67
+ /** @returns {{accounts: Record<string, any>, repos: Record<string, any>}} */
68
+ export function readConfig() {
69
+ try {
70
+ const raw = JSON.parse(readFileSync(configPath(), "utf8"));
71
+ return { accounts: raw.accounts ?? {}, repos: raw.repos ?? {} };
72
+ } catch {
73
+ // A missing or unreadable config is not an error here: the environment or a
74
+ // `.zumino.json` may still answer, and rung 6 is where refusal happens.
75
+ return { accounts: {}, repos: {} };
76
+ }
77
+ }
78
+
79
+ /** @param {{accounts: Record<string, any>, repos: Record<string, any>}} cfg */
80
+ export function writeConfig(cfg) {
81
+ const p = configPath();
82
+ mkdirSync(dirname(p), { recursive: true });
83
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
84
+ // `writeFileSync`'s mode applies only when it creates the file, so an existing
85
+ // one keeps whatever permissions it had. This holds 0600 on every write.
86
+ chmodSync(p, 0o600);
87
+ }
88
+
89
+ /**
90
+ * The nearest `.zumino.json`, walking up from `cwd`.
91
+ *
92
+ * Committed, and carries no secret — host and project are facts about the work
93
+ * and true for everyone who clones. Which *account* a given person uses for this
94
+ * repository is a fact about their laptop and lives in the home config instead.
95
+ */
96
+ export function readRepoFile(cwd = process.cwd()) {
97
+ let dir = resolve(cwd);
98
+ const { root } = parsePath(dir);
99
+ for (;;) {
100
+ try {
101
+ const raw = JSON.parse(readFileSync(join(dir, ".zumino.json"), "utf8"));
102
+ return { ...raw, path: join(dir, ".zumino.json") };
103
+ } catch {
104
+ /* keep walking */
105
+ }
106
+ if (dir === root) return null;
107
+ dir = dirname(dir);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * How this checkout is named in the home config's repo map.
113
+ *
114
+ * The remote URL first, because it survives a directory rename and distinguishes
115
+ * two clones of different projects that happen to share a basename. The basename
116
+ * is the fallback for a checkout with no remote — a fresh `git init`, or a
117
+ * worktree of something never pushed — because refusing those would make the map
118
+ * useless in exactly the case a new project starts from.
119
+ */
120
+ export function repoKeys(cwd = process.cwd()) {
121
+ const keys = [];
122
+ try {
123
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ stdio: ["ignore", "pipe", "ignore"],
127
+ }).trim();
128
+ if (url) keys.push(url);
129
+ } catch {
130
+ /* not a repo, or no origin */
131
+ }
132
+ try {
133
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
134
+ cwd,
135
+ encoding: "utf8",
136
+ stdio: ["ignore", "pipe", "ignore"],
137
+ }).trim();
138
+ if (top) keys.push(parsePath(top).base);
139
+ } catch {
140
+ keys.push(parsePath(resolve(cwd)).base);
141
+ }
142
+ return keys;
143
+ }
144
+
145
+ /**
146
+ * Resolve host, token and project, and say which rung answered.
147
+ *
148
+ * The chain is fixed and each rung is narrower than the one below it, so the
149
+ * most specific statement of intent always wins:
150
+ *
151
+ * 1. explicit flags the caller said so on this command
152
+ * 2. ZUMINO_TOKEN / ZUMINO_URL this process, and no other
153
+ * 3. .zumino.json this checkout, for everyone who clones
154
+ * 4. the home config's repo map this checkout, for this person
155
+ * 5. the only account there is unambiguous by arithmetic
156
+ * 6. refuse never guess
157
+ *
158
+ * `source` is not diagnostics. `zumino auth status` prints it, and it is what
159
+ * makes "who am I about to write as" a question with a visible answer.
160
+ *
161
+ * @param {{token?: string, host?: string, project?: string, account?: string}} flags
162
+ * @param {string} [cwd]
163
+ */
164
+ export function resolveContext(flags = {}, cwd = process.cwd()) {
165
+ // Every credential source is checked for an explicit-but-empty value, not
166
+ // just the two flags. Automation that exports `ZUMINO_TOKEN=''` because a
167
+ // secret did not populate would otherwise skip that rung on truthiness and
168
+ // run the mutation as the stored personal account — the same failure the flag
169
+ // rungs were fixed for, one source over.
170
+ for (const name of ["ZUMINO_TOKEN", "ZUMINO_ACCOUNT", "ZUMINO_URL"]) {
171
+ const v = process.env[name];
172
+ if (v !== undefined && !v.trim()) {
173
+ throw new CliError(`${name} is set but empty.`, {
174
+ exitCode: EXIT_UNRESOLVED,
175
+ hint: "Unset it, or give it a value — refusing to fall back while it names a credential.",
176
+ });
177
+ }
178
+ }
179
+
180
+ const cfg = readConfig();
181
+ const repoFile = readRepoFile(cwd);
182
+
183
+ // Project is resolved independently of identity: a flag beats the environment
184
+ // beats the checkout's own file. Many commands do not need one at all.
185
+ const project =
186
+ flags.project ?? process.env.ZUMINO_PROJECT ?? repoFile?.project ?? null;
187
+ // Every write path names its workspace (`DOMAIN.md` — the segment is a
188
+ // confirmation, not just an address), so it is resolved the same way. It may
189
+ // stay null: `client.js` looks it up from the project when it has to, and
190
+ // `zumino init` pins it so that lookup happens once rather than every command.
191
+ const workspace =
192
+ flags.workspace ?? process.env.ZUMINO_WORKSPACE ?? repoFile?.workspace ?? null;
193
+
194
+ // `!== undefined`, not truthiness. `--token "$BOT_TOKEN"` with the variable
195
+ // unset used to skip this rung silently and fall through to the stored
196
+ // personal account — so a script that named a credential explicitly ran its
197
+ // mutation as somebody else. An explicit empty value is an error, not an
198
+ // absence.
199
+ if (flags.token !== undefined) {
200
+ if (!String(flags.token).trim()) {
201
+ throw new CliError("--token was given but is empty.", {
202
+ exitCode: EXIT_UNRESOLVED,
203
+ hint: "Refusing to fall back to a stored account when a credential was named explicitly.",
204
+ });
205
+ }
206
+ return mk("flag (--token)", flags.host ?? process.env.ZUMINO_URL, flags.token, project, workspace, repoFile);
207
+ }
208
+
209
+ if (flags.account !== undefined) {
210
+ if (!String(flags.account).trim()) {
211
+ throw new CliError("--account was given but is empty.", {
212
+ exitCode: EXIT_UNRESOLVED,
213
+ hint: "Refusing to fall back to another account when one was named explicitly.",
214
+ });
215
+ }
216
+ const acct = cfg.accounts[flags.account];
217
+ if (!acct) {
218
+ throw new CliError(`No account named "${flags.account}".`, {
219
+ exitCode: EXIT_UNRESOLVED,
220
+ hint: `Known accounts: ${Object.keys(cfg.accounts).join(", ") || "none"}. Add one with: zumino auth login`,
221
+ });
222
+ }
223
+ return mk(`account "${flags.account}" (--account)`, flags.host ?? process.env.ZUMINO_URL ?? acct.host, acct.token, project, workspace, repoFile, flags.account);
224
+ }
225
+
226
+ if (process.env.ZUMINO_TOKEN) {
227
+ const host = flags.host ?? process.env.ZUMINO_URL;
228
+ return mk("environment (ZUMINO_TOKEN)", host, process.env.ZUMINO_TOKEN, project, workspace, repoFile);
229
+ }
230
+
231
+ if (process.env.ZUMINO_ACCOUNT) {
232
+ const name = process.env.ZUMINO_ACCOUNT;
233
+ const acct = cfg.accounts[name];
234
+ if (!acct) {
235
+ throw new CliError(`ZUMINO_ACCOUNT names "${name}", which is not configured.`, {
236
+ exitCode: EXIT_UNRESOLVED,
237
+ hint: `Known accounts: ${Object.keys(cfg.accounts).join(", ") || "none"}.`,
238
+ });
239
+ }
240
+ return mk(`account "${name}" (ZUMINO_ACCOUNT)`, flags.host ?? process.env.ZUMINO_URL ?? acct.host, acct.token, project, workspace, repoFile, name);
241
+ }
242
+
243
+ // There is deliberately no rung here that lets `.zumino.json` name an account.
244
+ //
245
+ // It had one, and it contradicted `docs/decisions/0006`: a committed file
246
+ // states what to work on, and which *credential* is used here is a fact about
247
+ // this laptop that lives in the home config's repo map below. Binding the
248
+ // token to its own account's host stopped a repo exfiltrating it, but a repo
249
+ // could still *select* a more privileged identity by guessing an account name
250
+ // — so writes from a checkout you did not author were recorded as that user.
251
+ //
252
+ // Nothing is lost: the repo map at the next rung gives an agent in a checkout
253
+ // the same "no environment needed" resolution, keyed by the checkout's own
254
+ // remote, from a file only its owner can write.
255
+
256
+ for (const key of repoKeys(cwd)) {
257
+ const mapped = cfg.repos[key];
258
+ if (!mapped) continue;
259
+ const acct = cfg.accounts[mapped.account];
260
+ if (!acct) continue;
261
+ return mk(
262
+ `account "${mapped.account}" (repo map: ${key})`,
263
+ flags.host ?? process.env.ZUMINO_URL ?? acct.host,
264
+ acct.token,
265
+ project ?? mapped.project ?? null,
266
+ workspace ?? mapped.workspace ?? null,
267
+ repoFile,
268
+ mapped.account,
269
+ );
270
+ }
271
+
272
+ const names = Object.keys(cfg.accounts);
273
+ if (names.length === 1) {
274
+ const acct = cfg.accounts[names[0]];
275
+ return mk(
276
+ `the only account ("${names[0]}")`,
277
+ flags.host ?? process.env.ZUMINO_URL ?? acct.host,
278
+ acct.token,
279
+ project,
280
+ workspace,
281
+ repoFile,
282
+ names[0],
283
+ );
284
+ }
285
+
286
+ throw new CliError(
287
+ names.length
288
+ ? "Several accounts are configured and nothing here says which to use."
289
+ : "No Zumino account is configured.",
290
+ {
291
+ exitCode: EXIT_UNRESOLVED,
292
+ hint: names.length
293
+ ? `Pass --account <${names.join("|")}>, set ZUMINO_ACCOUNT, or map this repo: zumino init`
294
+ : "Run: zumino auth login",
295
+ },
296
+ );
297
+ }
298
+
299
+ const trimHost = (h) => String(h).replace(/\/+$/, "");
300
+
301
+ /**
302
+ * Bind a credential to a host, and refuse to let a repository redirect it.
303
+ *
304
+ * **`.zumino.json` is committed and carries no secret — but `host` decides
305
+ * where the secret is *sent*.** A checkout is found by walking up from the
306
+ * working directory, so any ancestor answers, and the CLI's own documented
307
+ * workflow is an agent running inside a repository it did not write. If a repo
308
+ * file could name the host, cloning a hostile one and running any command would
309
+ * post a `zumino_live_…` token — which authenticates as its owner across every
310
+ * workspace they belong to — to whatever origin that file chose, before
311
+ * anything is validated.
312
+ *
313
+ * So the credential's own host wins, always, and a repo file may only *agree*
314
+ * with it. A repo file naming a different host is a hard refusal rather than a
315
+ * silent redirect, and a repo file that is the sole source of a host is refused
316
+ * too: something the caller controls — `--host`, `ZUMINO_URL`, or the account
317
+ * the token came from — has to say where a credential may go.
318
+ *
319
+ * @returns {{host: string, token: string, project: string|null, workspace: string|null, source: string}}
320
+ */
321
+ function mk(source, credentialHost, token, project, workspace = null, repoFile = null, accountName = null) {
322
+ const repoHost = repoFile?.host ? trimHost(repoFile.host) : null;
323
+ const host = credentialHost ? trimHost(credentialHost) : null;
324
+
325
+ if (host && repoHost && host !== repoHost) {
326
+ throw new CliError(
327
+ `${safeText(repoFile.path)} names host ${safeText(repoHost)}, but this credential belongs to ${host}.`,
328
+ {
329
+ exitCode: EXIT_UNRESOLVED,
330
+ hint:
331
+ "Refusing to send a token to a host the repository chose. " +
332
+ `If ${safeText(repoHost)} is right, use an account for it or pass --host ${safeText(repoHost)}.`,
333
+ },
334
+ );
335
+ }
336
+
337
+ if (!host) {
338
+ throw new CliError(
339
+ repoHost
340
+ ? `${safeText(repoFile.path)} names a host, but nothing says which credential may be sent there.`
341
+ : "No Zumino host is configured.",
342
+ {
343
+ exitCode: EXIT_UNRESOLVED,
344
+ hint: repoHost
345
+ ? `Run: zumino auth login ${safeText(repoHost)} (or set ZUMINO_URL / pass --host)`
346
+ : "Set ZUMINO_URL, pass --host, or run: zumino init",
347
+ },
348
+ );
349
+ }
350
+
351
+ const ws = typeof workspace === "string" ? workspace : (workspace?.slug ?? null);
352
+ const origin = repoFile?.path ?? "the environment or a flag";
353
+ return {
354
+ source,
355
+ account: accountName,
356
+ host,
357
+ token,
358
+ project: requireSafeSegment(project, "project", origin),
359
+ workspace: requireSafeSegment(ws, "workspace", origin),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * The project a command needs, or a refusal that says how to supply one.
365
+ * @param {{project: string|null}} ctx
366
+ */
367
+ export function requireProject(ctx) {
368
+ if (ctx.project) return ctx.project;
369
+ throw new CliError("No project. This command needs one.", {
370
+ exitCode: EXIT_UNRESOLVED,
371
+ hint: "Pass --project <slug>, set ZUMINO_PROJECT, or run: zumino init",
372
+ });
373
+ }
package/src/errors.js ADDED
@@ -0,0 +1,47 @@
1
+ /*
2
+ * What the CLI exits with, and why each code is its own.
3
+ *
4
+ * A wrapper — a git hook, a CI step, an agent's shell — branches on the number.
5
+ * Parsing the message instead is what we are trying to spare them, so the codes
6
+ * are part of the contract and are documented in `README.md`.
7
+ */
8
+
9
+ /** Ordinary failure: the server refused, the arguments were wrong. */
10
+ export const EXIT_FAILURE = 1;
11
+ /** Nothing resolved an account, host or project. Fix the configuration. */
12
+ export const EXIT_UNRESOLVED = 2;
13
+ /** This CLI is older than the server allows. Run `zumino self-update`. */
14
+ export const EXIT_TOO_OLD = 3;
15
+
16
+ export class CliError extends Error {
17
+ /**
18
+ * @param {string} message
19
+ * @param {{exitCode?: number, hint?: string, key?: string}} [opts]
20
+ */
21
+ constructor(message, opts = {}) {
22
+ super(message);
23
+ this.name = "CliError";
24
+ this.exitCode = opts.exitCode ?? EXIT_FAILURE;
25
+ /** A second line telling the caller what to *do*. Agents act on these. */
26
+ this.hint = opts.hint;
27
+ /** The server's stable `errors.*` key, when the failure came from the API. */
28
+ this.key = opts.key;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The server says this CLI is too old to be answered.
34
+ *
35
+ * Deliberately fatal rather than a warning. The whole point of the handshake is
36
+ * that a client which no longer matches the shapes it was built against must not
37
+ * produce a *plausible* answer — silently wrong output from a stale client is
38
+ * the failure this exists to prevent.
39
+ *
40
+ * @param {string} ours @param {string} min @param {string} host
41
+ */
42
+ export function tooOld(ours, min, host) {
43
+ return new CliError(
44
+ `CLI ${ours} is too old for ${host}, which requires >= ${min}.`,
45
+ { exitCode: EXIT_TOO_OLD, hint: "Run: zumino self-update" },
46
+ );
47
+ }