@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
|
@@ -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
|
+
}
|