@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/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# `@zumino/cli`
|
|
2
|
+
|
|
3
|
+
Drive [Zumino](https://zumino.cc) from a terminal, a script, or an agent.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g @zumino/cli
|
|
7
|
+
zumino auth login
|
|
8
|
+
zumino init # in a repo — ties it to a project
|
|
9
|
+
zumino queue
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why a CLI and not a library
|
|
13
|
+
|
|
14
|
+
There is nothing to import. The API is the product, and this is a thin front-end
|
|
15
|
+
over it — every endpoint stays reachable through `zumino api`, including ones
|
|
16
|
+
added after you installed this.
|
|
17
|
+
|
|
18
|
+
What the CLI adds that `curl` cannot is **a version handshake**. Every response
|
|
19
|
+
carries the oldest CLI the server will answer and the newest one published, so a
|
|
20
|
+
stale client is told on the call it was already making, and an incompatible one
|
|
21
|
+
refuses to run rather than returning a plausible answer built on shapes the
|
|
22
|
+
server no longer sends.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
zumino queue [--needs-input] [--limit N] what to work on next
|
|
28
|
+
zumino context <CODE> [--activity] the whole brief for one task
|
|
29
|
+
zumino find <query> [--kind k] one read across every kind
|
|
30
|
+
|
|
31
|
+
zumino task create --title T [--description D] [--epic N]
|
|
32
|
+
zumino task status <CODE> <status>
|
|
33
|
+
zumino task assign <CODE> <userId|->
|
|
34
|
+
zumino task spec <CODE> --plan T | --acceptance T
|
|
35
|
+
zumino task comment <CODE> <text>
|
|
36
|
+
zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>
|
|
37
|
+
zumino task ref <CODE> --url URL [--title T]
|
|
38
|
+
zumino task attention <CODE> [reason|-]
|
|
39
|
+
zumino task show <CODE>
|
|
40
|
+
|
|
41
|
+
zumino request create|answer|status|note|comment|show
|
|
42
|
+
zumino request promote <CODE|#N> --to <work-project>
|
|
43
|
+
zumino epic create|status|list|show # address as E6 or ACME-E6
|
|
44
|
+
|
|
45
|
+
zumino api <METHOD> <PATH> [--body JSON]
|
|
46
|
+
zumino auth login | status | list | logout
|
|
47
|
+
zumino init
|
|
48
|
+
zumino skill install [--dir D] [--check]
|
|
49
|
+
zumino self-update
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--json` prints the raw response instead of a table, on every command that
|
|
53
|
+
returns API data. The housekeeping commands — `auth login`/`logout`, `init`,
|
|
54
|
+
`skill install`, `self-update` — report progress in prose and ignore it.
|
|
55
|
+
|
|
56
|
+
## Where context comes from
|
|
57
|
+
|
|
58
|
+
Resolved in this order. `zumino auth status` says which rung answered.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
1. --token / --account / --host / --project
|
|
62
|
+
2. ZUMINO_TOKEN / ZUMINO_URL / ZUMINO_PROJECT / ZUMINO_WORKSPACE / ZUMINO_ACCOUNT
|
|
63
|
+
3. .zumino.json in the repo committed; host, workspace, project, no secret
|
|
64
|
+
4. the repo map in ~/.config/zumino/config.json which account you use here
|
|
65
|
+
5. the only account configured
|
|
66
|
+
6. refuse
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**There is no "current account" and no `switch` command.** One person may hold
|
|
70
|
+
several accounts on several hosts, and two agents may run in two checkouts at
|
|
71
|
+
once — a single mutable pointer in the home directory would let either silently
|
|
72
|
+
change the other's identity, and a tracker write landing as the wrong person
|
|
73
|
+
looks exactly like success.
|
|
74
|
+
|
|
75
|
+
## Exit codes
|
|
76
|
+
|
|
77
|
+
| code | meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `0` | fine |
|
|
80
|
+
| `1` | the call failed |
|
|
81
|
+
| `2` | nothing resolved an account, host or project |
|
|
82
|
+
| `3` | this CLI is too old for the server — run `zumino self-update` |
|
|
83
|
+
|
|
84
|
+
## Environment
|
|
85
|
+
|
|
86
|
+
| | |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `ZUMINO_TOKEN` | a personal access token, `zumino_live_…` |
|
|
89
|
+
| `ZUMINO_URL` | host origin, no trailing slash and no `/api` |
|
|
90
|
+
| `ZUMINO_PROJECT` / `ZUMINO_WORKSPACE` | slugs |
|
|
91
|
+
| `ZUMINO_ACCOUNT` | a name from `zumino auth list` |
|
|
92
|
+
| `ZUMINO_NO_UPDATE_CHECK` | silences the "you are behind" notice. It cannot silence the refusal |
|
|
93
|
+
|
|
94
|
+
Notices go to stderr and the answer goes to stdout, so `zumino queue --json \| jq`
|
|
95
|
+
is always safe.
|
|
96
|
+
|
|
97
|
+
## The agent skill
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
zumino skill install # ~/.claude/skills/zumino/ — once, globally
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Installed globally on purpose: a per-repo copy is N copies of one document, each
|
|
104
|
+
aging separately with nothing to say which is current.
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
Node 24 or newer. No runtime dependencies.
|
package/bin/zumino.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/main.js";
|
|
3
|
+
|
|
4
|
+
// The CLI owns its own exit code (see `src/errors.js`), so nothing here maps
|
|
5
|
+
// a thrown value to a status — `main` has already decided, and rethrowing would
|
|
6
|
+
// replace a deliberate 3 with node's 1.
|
|
7
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zumino/cli",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Drive Zumino from a terminal, a script, or an agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"zumino": "bin/zumino.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"skill",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/skkap/zumino.git",
|
|
22
|
+
"directory": "cli"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"zumino",
|
|
26
|
+
"issue-tracker",
|
|
27
|
+
"agents",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zumino
|
|
3
|
+
version: 2.1.0
|
|
4
|
+
description: Read and modify Zumino projects from any directory with the `zumino` CLI. Two kinds of project — feedback projects (requests, votes, roadmap statuses, read publicly as a board) and work projects (tasks and epics, with a structured spec, an owner, and a readiness bar) — plus one generic read across every kind of item. Use whenever the user mentions Zumino, a product board, a public roadmap, requests or feedback, a project, a task, an epic, a backlog item, a spec, or asks to file/track/pick up a bug, feature request, idea, or piece of work.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Zumino — projects from the command line
|
|
8
|
+
|
|
9
|
+
Everything is `zumino`, installed globally and working from any directory. Run
|
|
10
|
+
`zumino --help` for the full surface; this file is the part `--help` cannot
|
|
11
|
+
carry — **what the words mean, and what will go wrong if you assume.**
|
|
12
|
+
|
|
13
|
+
If `zumino` is not installed: `npm i -g @zumino/cli`.
|
|
14
|
+
|
|
15
|
+
## The command shape, in one line
|
|
16
|
+
|
|
17
|
+
**Generic reads span every kind; every write names its kind.** `zumino queue`,
|
|
18
|
+
`zumino context ONS-14` and `zumino find` answer across tasks, requests and
|
|
19
|
+
epics. Anything that writes is `zumino task …`, `zumino request …` or
|
|
20
|
+
`zumino epic …`. That is the API's own rule, and the CLI mirrors it so that a
|
|
21
|
+
new kind of item adds a noun rather than changing what you already know.
|
|
22
|
+
|
|
23
|
+
Anything not in the verb layer is still reachable:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
zumino api GET /openapi.json # every endpoint and every accepted value
|
|
27
|
+
zumino api POST /workspaces/acme/projects/app/tasks --body '{"title":"…"}'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Start here, every session
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
zumino auth status # who am I, against which host, from which source
|
|
34
|
+
zumino queue # what can I pick up right now
|
|
35
|
+
zumino context ONS-14 # the whole brief for one task, as markdown
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`zumino context` is not a convenience. It assembles the epic, the description,
|
|
39
|
+
the plan, the acceptance criteria, the blockers and the requests a task answers
|
|
40
|
+
into one document. Six separate reads would let you lay them out yourself, and
|
|
41
|
+
that is exactly the failure: you would assemble a *plausible* brief instead of
|
|
42
|
+
the real one.
|
|
43
|
+
|
|
44
|
+
Add `--activity` when picking up work someone (or some earlier run) already
|
|
45
|
+
touched — it appends what has been tried.
|
|
46
|
+
|
|
47
|
+
## Read the domain model first
|
|
48
|
+
|
|
49
|
+
**If you are in the Zumino repo, read `DOMAIN.md` before you write anything.**
|
|
50
|
+
It is the one place the vocabulary is written down, and the registries in
|
|
51
|
+
`src/lib` are what the server actually enforces, held in step by
|
|
52
|
+
`test/domain-vocabulary.test.ts`.
|
|
53
|
+
|
|
54
|
+
This file deliberately carries **no copy** of any status list or kind list. A
|
|
55
|
+
second list is accurate the day it is written and wrong a release later, and you
|
|
56
|
+
would have no way of telling which one you were reading. From outside the repo,
|
|
57
|
+
`zumino api GET /openapi.json` enumerates every value the API accepts, and an
|
|
58
|
+
invalid one is refused with a message naming what was expected. Ask rather than
|
|
59
|
+
guess.
|
|
60
|
+
|
|
61
|
+
## Three words mean something narrower here
|
|
62
|
+
|
|
63
|
+
Each of these will bite an agent specifically.
|
|
64
|
+
|
|
65
|
+
| Word | Here | Not |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| **ref** | a **pull request** attached to a task — a URL and a title, nothing more. `zumino task ref ONS-14 --url …` attaches one | *not* the identifier of an item. That is a **code**. The API publishes the code under two spellings (`code` and `ref`) depending on which endpoint you asked; the CLI reads past that and always prints **code**. Say "code" to a person |
|
|
68
|
+
| **key** | the **project key** — the permanent uppercase prefix a code is built from (`ONS`), unique per workspace, and **absent on a feedback project** | *not* the project's slug (the `--project` value), and *not* `publicSlug` (only the `/b/{slug}` page's address) |
|
|
69
|
+
| **backlog** | a task **status**, and a **view** of a project | *not* "everything not yet done". `todo`, `shaping` and the rest are not in the backlog; they are committed work |
|
|
70
|
+
|
|
71
|
+
Two consequences, because both look like bugs otherwise:
|
|
72
|
+
|
|
73
|
+
- **A request on a feedback project has no code.** The project has no key, so
|
|
74
|
+
`#42` is the whole of its name. Do not synthesize one.
|
|
75
|
+
- **Nothing in `backlog` is offered by `zumino queue`.**
|
|
76
|
+
|
|
77
|
+
## `zumino queue` is computed, not a column
|
|
78
|
+
|
|
79
|
+
The default mode answers with **available work**: a task that is `todo` or
|
|
80
|
+
`shaping`, that meets the bar (a description **and** acceptance criteria), with
|
|
81
|
+
no unfinished task blocking it. All three are computed. There is no status
|
|
82
|
+
anyone drags a card into to publish work here.
|
|
83
|
+
|
|
84
|
+
**An empty queue does not mean there is nothing to do.** It means every
|
|
85
|
+
committed task is either below the bar or waiting on something. That is a
|
|
86
|
+
finding worth reporting, not a reason to stop:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Committed but not yet shaped — the queue will not offer these.
|
|
90
|
+
zumino find --state open --json | jq -r '.[] | select(.kind=="task") | .code'
|
|
91
|
+
zumino task show ONS-14 # readiness is on the task
|
|
92
|
+
|
|
93
|
+
`find` takes `--state open|closed`, never `--status`: the read that spans every
|
|
94
|
+
kind publishes no per-kind status (`docs/decisions/0003`). A status belongs to a
|
|
95
|
+
kind, so ask that kind — `zumino task show`.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Shape one of those and it appears in the queue by itself. **Do not invent a
|
|
99
|
+
description or a criterion to make a task eligible** — that is laundering an
|
|
100
|
+
unshaped task as a shaped one, and the whole point of the bar is that it cannot
|
|
101
|
+
be satisfied by filler.
|
|
102
|
+
|
|
103
|
+
The bar does **not** require a plan. A task can legitimately arrive with no plan
|
|
104
|
+
and expect you to work the approach out; the brief says so when that is the case.
|
|
105
|
+
|
|
106
|
+
`--needs-input` is the other mode: tasks waiting on you.
|
|
107
|
+
|
|
108
|
+
## Every repo knows its project
|
|
109
|
+
|
|
110
|
+
A tracker only tells the truth if the work goes through it. The failure mode is
|
|
111
|
+
not that people refuse to file tickets — it is that an agent doing an hour of
|
|
112
|
+
real work in a repo has no reason to know a project exists, so the work happens
|
|
113
|
+
and the board silently falls behind.
|
|
114
|
+
|
|
115
|
+
**Two conventions close that.**
|
|
116
|
+
|
|
117
|
+
**1. A repo declares its project.** `zumino init` writes `.zumino.json` at the
|
|
118
|
+
repo root — host, workspace, project, no secret — and every `zumino` command in
|
|
119
|
+
that checkout then needs no arguments. If you are in a repo with no
|
|
120
|
+
`.zumino.json` and the user is tracking work in Zumino, offer to run it.
|
|
121
|
+
|
|
122
|
+
**2. Real work finds or files a ticket.** Not every edit — a typo fix does not
|
|
123
|
+
need one, and a rule that says otherwise gets ignored wholesale. But **work with
|
|
124
|
+
a shape to it** starts by finding the existing ticket or creating one, and ends
|
|
125
|
+
by closing it, commenting on it, or moving it. The threshold: if you would
|
|
126
|
+
mention it in a standup, it needs a ticket.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
zumino find "rate limit" # is this already filed?
|
|
130
|
+
zumino task create --title "…" --description "…" # if not
|
|
131
|
+
zumino task ref ONS-14 --url <the PR you opened> # when you open one
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Before finishing a piece of work, check the backlog.** A change that resolves,
|
|
135
|
+
contradicts or partially implements something already filed should say so on
|
|
136
|
+
that ticket rather than leaving it to be reconciled later.
|
|
137
|
+
|
|
138
|
+
## Addressing an epic
|
|
139
|
+
|
|
140
|
+
`zumino epic create` prints `E6`. That is a real address in the current project —
|
|
141
|
+
`zumino epic show E6` works — and so is the full code `ACME-E6`, which is what
|
|
142
|
+
`zumino find --kind epic` shows. A bare `6` means *task* 6, because task and epic
|
|
143
|
+
numbers are separate namespaces, so the letter is what tells them apart.
|
|
144
|
+
|
|
145
|
+
## The three pieces of writing
|
|
146
|
+
|
|
147
|
+
A task carries a `description` (what this is), `spec.plan` (the output of
|
|
148
|
+
shaping) and `spec.acceptance` (when it is done). The two spec sections are
|
|
149
|
+
written **one at a time**, and the CLI enforces it:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
zumino task spec ONS-14 --plan "…"
|
|
153
|
+
zumino task spec ONS-14 --acceptance "…"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That is not ceremony. An agent redrafting the plan must not be able to touch the
|
|
157
|
+
criteria it will be judged against.
|
|
158
|
+
|
|
159
|
+
## Linking work
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
zumino task link ONS-14 blocks ONS-9 # ONS-9 waits on ONS-14
|
|
163
|
+
zumino task link ONS-14 blocked-by ONS-9 # the other direction
|
|
164
|
+
zumino task link ONS-14 related ONS-3 # worth reading together, nothing waits
|
|
165
|
+
zumino task link ONS-14 answers feedback#42 # this work exists because of that request
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`answers` is the useful one and the odd one: the far end is a **request**, not a
|
|
169
|
+
task, and it needs owner or admin on the request's project. A request is never
|
|
170
|
+
*converted* into a task — promoting one creates a task that answers it, and both
|
|
171
|
+
go on living:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
zumino request promote 42 --project feedback --to app
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`--project` is where the request lives, `--to` is where the task goes. They are
|
|
178
|
+
two different projects, which is why they are two different flags.
|
|
179
|
+
|
|
180
|
+
## Errors
|
|
181
|
+
|
|
182
|
+
Failures carry a stable key and an English message; the CLI prints the message
|
|
183
|
+
and, where there is one, a second line telling you what to do. Act on that line.
|
|
184
|
+
|
|
185
|
+
**Exit codes are the contract**, so branch on them rather than parsing text:
|
|
186
|
+
|
|
187
|
+
| code | meaning |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `0` | fine |
|
|
190
|
+
| `1` | the call failed — the message says why |
|
|
191
|
+
| `2` | nothing resolved an account, host or project |
|
|
192
|
+
| `3` | **this CLI is too old for the server** — run `zumino self-update` and retry |
|
|
193
|
+
|
|
194
|
+
**A rate-limit refusal means wait and repeat that exact call — never skip it and
|
|
195
|
+
never shorten it.** The message carries the exact number of seconds. A
|
|
196
|
+
half-authored epic looks finished, which is worse than one not started. If you
|
|
197
|
+
cannot wait, stop and say which calls landed and which did not.
|
|
198
|
+
|
|
199
|
+
## Rules
|
|
200
|
+
|
|
201
|
+
- **Never destroy anything.** There is no `zumino delete`, deliberately: an
|
|
202
|
+
item's history cascades with the row, so deletion is the one operation that
|
|
203
|
+
erases its own evidence. Retire work with `wont_do`.
|
|
204
|
+
- **Never write text in the user's name without showing it first.** Requests and
|
|
205
|
+
comments on a public board are visible to everyone.
|
|
206
|
+
- **Never overwrite a task's `description` or its `acceptance`** without being
|
|
207
|
+
asked. Those are the human's half: what the work is, and what it will be
|
|
208
|
+
judged against.
|
|
209
|
+
- **Never mark a task `done`.** Acceptance is a person's call. Move it to
|
|
210
|
+
`in_review` and say what you did.
|
|
211
|
+
- If a task's readiness is below the bar, **raise attention rather than filling
|
|
212
|
+
the gap with a plausible guess**: `zumino task attention ONS-14 "…"`.
|
|
213
|
+
- If a project is ambiguous or missing, run `zumino api GET /projects` and ask —
|
|
214
|
+
do not guess.
|
|
215
|
+
- An **assignee is always a person**, even when an agent does the work. There is
|
|
216
|
+
no agent identity; a token acts as its owner, and that is who the history
|
|
217
|
+
records.
|
|
218
|
+
- Titles are one line and specific. Descriptions carry repro steps, file paths
|
|
219
|
+
and links. Do not pad.
|
|
220
|
+
|
|
221
|
+
## When something says you are out of date
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
zumino: CLI 2.0.0 is too old for https://zumino.cc, which requires >= 2.1.0.
|
|
225
|
+
Run: zumino self-update
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Run it, then retry what you were doing. The CLI refuses rather than guessing
|
|
229
|
+
because a client built against shapes the server no longer sends would produce a
|
|
230
|
+
*plausible* answer, and a plausible answer is worse than none.
|
|
231
|
+
|
|
232
|
+
If the CLI is newer than this skill, refresh it: `zumino skill install`.
|
package/src/client.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
import { CliError, tooOld } from "./errors.js";
|
|
7
|
+
import { note, safeText, workspaceSlug } from "./output.js";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
export const VERSION = require("../package.json").version;
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* One HTTP call, and the version handshake that rides on its reply.
|
|
14
|
+
*
|
|
15
|
+
* The handshake is the reason this CLI is worth having over `curl`. Every
|
|
16
|
+
* `/api/v1` response carries `X-Zumino-Cli-Min` and `X-Zumino-Cli-Latest`
|
|
17
|
+
* (`src/lib/cli-version.ts`), so staleness is detected on the call the caller
|
|
18
|
+
* was already making — no version endpoint, no extra round trip, and no way to
|
|
19
|
+
* be silently out of date.
|
|
20
|
+
*
|
|
21
|
+
* Two tiers, and the difference between them is the whole design:
|
|
22
|
+
*
|
|
23
|
+
* - **below the minimum** — fatal, every time, uncached. The shapes this CLI
|
|
24
|
+
* was built against are no longer what the server sends, so a plausible
|
|
25
|
+
* answer is worse than no answer.
|
|
26
|
+
* - **merely behind** — one line on stderr, at most once a day per host. An
|
|
27
|
+
* agent making forty calls in a session must not read the same notice forty
|
|
28
|
+
* times; that is context it pays for and cannot act on twice.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** @param {string} host */
|
|
32
|
+
function noticeCachePath() {
|
|
33
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
|
|
34
|
+
return join(base, "zumino", "update-notice.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/** True at most once per day per (host, version we would recommend). */
|
|
40
|
+
function shouldNag(host, latest) {
|
|
41
|
+
if (process.env.ZUMINO_NO_UPDATE_CHECK) return false;
|
|
42
|
+
const key = `${host}@${latest}`;
|
|
43
|
+
const path = noticeCachePath();
|
|
44
|
+
let seen = {};
|
|
45
|
+
try {
|
|
46
|
+
seen = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
} catch {
|
|
48
|
+
/* no cache yet */
|
|
49
|
+
}
|
|
50
|
+
if (typeof seen[key] === "number" && Date.now() - seen[key] < DAY_MS) return false;
|
|
51
|
+
try {
|
|
52
|
+
seen[key] = Date.now();
|
|
53
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
54
|
+
writeFileSync(path, JSON.stringify(seen));
|
|
55
|
+
} catch {
|
|
56
|
+
// A read-only or absent cache directory must not stop the command. The cost
|
|
57
|
+
// of failing to record is a repeated notice, which is noise, not breakage.
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Enforce the floor, advise about the ceiling.
|
|
64
|
+
*
|
|
65
|
+
* `ZUMINO_NO_UPDATE_CHECK` silences the *advice* only. It deliberately cannot
|
|
66
|
+
* suppress the refusal: an environment variable that let a known-incompatible
|
|
67
|
+
* client keep running would defeat the one guarantee this handshake makes.
|
|
68
|
+
*/
|
|
69
|
+
export function evaluateVersion(ours, min, latest) {
|
|
70
|
+
if (min && cmp(ours, min) < 0) return { verdict: "refuse", min };
|
|
71
|
+
if (latest && cmp(ours, latest) < 0) return { verdict: "behind", latest };
|
|
72
|
+
return { verdict: "ok" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkVersion(res, host) {
|
|
76
|
+
const { verdict, min, latest } = evaluateVersion(
|
|
77
|
+
VERSION,
|
|
78
|
+
res.headers.get("x-zumino-cli-min"),
|
|
79
|
+
res.headers.get("x-zumino-cli-latest"),
|
|
80
|
+
);
|
|
81
|
+
if (verdict === "refuse") throw tooOld(VERSION, min, host);
|
|
82
|
+
if (verdict === "behind" && shouldNag(host, latest)) {
|
|
83
|
+
note(
|
|
84
|
+
`zumino: CLI ${VERSION} is behind ${latest} on ${host}.\n` +
|
|
85
|
+
` Update with: zumino self-update`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cmp(a, b) {
|
|
91
|
+
const pa = String(a).split(".").map(Number);
|
|
92
|
+
const pb = String(b).split(".").map(Number);
|
|
93
|
+
for (let i = 0; i < 3; i++) {
|
|
94
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
95
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Call the API.
|
|
102
|
+
*
|
|
103
|
+
* @param {{host: string, token: string}} ctx
|
|
104
|
+
* @param {string} method
|
|
105
|
+
* @param {string} path below `/api/v1`, e.g. `/queue`
|
|
106
|
+
* @param {{query?: Record<string, any>, body?: any, raw?: boolean}} [opts]
|
|
107
|
+
*/
|
|
108
|
+
export async function api(ctx, method, path, opts = {}) {
|
|
109
|
+
const url = new URL(`${ctx.host}/api/v1${path}`);
|
|
110
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
111
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetch(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: {
|
|
119
|
+
Authorization: `Bearer ${ctx.token}`,
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
"X-Zumino-Cli": VERSION,
|
|
122
|
+
"User-Agent": `zumino-cli/${VERSION} node/${process.versions.node}`,
|
|
123
|
+
...(opts.body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
124
|
+
},
|
|
125
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
126
|
+
});
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
throw new CliError(`Cannot reach ${ctx.host}: ${cause.message}`, {
|
|
129
|
+
hint: "Check ZUMINO_URL, or the host in .zumino.json.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
checkVersion(res, ctx.host);
|
|
134
|
+
|
|
135
|
+
// Two ways a reply legitimately carries nothing, and they have to be taken
|
|
136
|
+
// before the body guards below or those guards fire on every one of them.
|
|
137
|
+
//
|
|
138
|
+
// HEAD is the one that was missed: it carries no body *by definition*, Next
|
|
139
|
+
// auto-implements it from the GET handler, and it is reachable through
|
|
140
|
+
// `zumino api HEAD …`. Skipping only the empty-body check was not enough —
|
|
141
|
+
// the empty string then reached `JSON.parse` and failed the non-JSON guard
|
|
142
|
+
// instead, which is the same defect one line further down.
|
|
143
|
+
if (res.status === 204 || method === "HEAD") {
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
throw new CliError(`${ctx.host} answered ${res.status}.`, {
|
|
146
|
+
hint: res.status === 401 ? "Token rejected. Check it with: zumino auth status" : undefined,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const text = await res.text();
|
|
153
|
+
|
|
154
|
+
// No route on this API answers 200 with an empty body — the empty ones are
|
|
155
|
+
// 204, handled above. So a zero-length body is a truncated or intercepted
|
|
156
|
+
// reply, and letting it through as `null` reproduces the same confident-empty
|
|
157
|
+
// failure the non-JSON guard below exists to stop.
|
|
158
|
+
if (!text) {
|
|
159
|
+
throw new CliError(`${ctx.host} answered ${res.status} with an empty body.`, {
|
|
160
|
+
hint: "The reply was truncated or intercepted; nothing was read.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
let parsed = null;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
// A non-JSON body from a JSON API means something in front of the app
|
|
168
|
+
// answered — a proxy, an SSO or captive-portal page, a `ZUMINO_URL` one hop
|
|
169
|
+
// short of the app.
|
|
170
|
+
//
|
|
171
|
+
// **This throws whatever the status was.** It used to raise only on `!ok`,
|
|
172
|
+
// so a `200 text/html` fell through as `null` and every caller's
|
|
173
|
+
// `res?.tasks ?? []` turned it into a confident empty answer at exit 0:
|
|
174
|
+
// `zumino queue` said there was nothing to pick up, `zumino find` said
|
|
175
|
+
// nothing matched — which is what an agent reads before filing a duplicate —
|
|
176
|
+
// and `auth status` reported success against a host that authenticated
|
|
177
|
+
// nothing. That is exactly the plausible-answer failure this file's header
|
|
178
|
+
// says the design exists to prevent.
|
|
179
|
+
throw new CliError(`${ctx.host} answered ${res.status} with a non-JSON body.`, {
|
|
180
|
+
hint: "Is ZUMINO_URL pointing at the app rather than a proxy or a login page?",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
const err = parsed?.error;
|
|
186
|
+
// `Retry-After` carries the exact wait (`src/lib/api-resource.ts`), so a
|
|
187
|
+
// caller sleeps the right amount rather than guessing or dropping the write.
|
|
188
|
+
const retry = res.headers.get("retry-after");
|
|
189
|
+
// The server's message is built from its own keys, but a Zod issue can echo
|
|
190
|
+
// a submitted value back, so it is treated as remote text like any other.
|
|
191
|
+
throw new CliError(safeText(err?.message) || `${method} ${path} failed (${res.status}).`, {
|
|
192
|
+
key: err?.key,
|
|
193
|
+
hint:
|
|
194
|
+
res.status === 429 && retry
|
|
195
|
+
? `Rate limited. Retry after ${retry}s.`
|
|
196
|
+
: res.status === 401
|
|
197
|
+
? "Token rejected. Check it with: zumino auth status"
|
|
198
|
+
: undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return parsed;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The workspace a project lives in, looked up when nothing named one.
|
|
207
|
+
*
|
|
208
|
+
* `GET /projects` is one of the two deliberately cross-workspace reads, so this
|
|
209
|
+
* is a single call rather than a search. It is a fallback and says so: `zumino
|
|
210
|
+
* init` writes the workspace into `.zumino.json` precisely to make it stop
|
|
211
|
+
* happening on every command.
|
|
212
|
+
*
|
|
213
|
+
* @param {{host: string, token: string, workspace: string|null, project: string|null}} ctx
|
|
214
|
+
*/
|
|
215
|
+
export async function resolveWorkspace(ctx) {
|
|
216
|
+
// Normalised rather than returned as-is: a `.zumino.json` written by an older
|
|
217
|
+
// build can hold the object shape, and it would otherwise be interpolated
|
|
218
|
+
// into every path in that checkout.
|
|
219
|
+
const named = workspaceSlug(ctx.workspace);
|
|
220
|
+
if (named) return named;
|
|
221
|
+
if (!ctx.project) {
|
|
222
|
+
throw new CliError("No project, so no workspace to find.", {
|
|
223
|
+
hint: "Pass --project <slug>, or run: zumino init",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const { projects = [] } = (await api(ctx, "GET", "/projects")) ?? {};
|
|
227
|
+
const hits = projects.filter(
|
|
228
|
+
(p) => p.slug === ctx.project || p.key === ctx.project,
|
|
229
|
+
);
|
|
230
|
+
if (hits.length === 0) {
|
|
231
|
+
throw new CliError(`No project "${ctx.project}" in any workspace you can reach.`, {
|
|
232
|
+
hint: "List what you can see with: zumino api GET /projects",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (hits.length > 1) {
|
|
236
|
+
// The slug is unique per workspace, not globally, so this is reachable and
|
|
237
|
+
// guessing would write to the wrong workspace — the exact failure the
|
|
238
|
+
// workspace segment exists to prevent.
|
|
239
|
+
throw new CliError(
|
|
240
|
+
`"${ctx.project}" exists in more than one workspace: ${hits
|
|
241
|
+
.map((h) => workspaceSlug(h.workspace))
|
|
242
|
+
.join(", ")}.`,
|
|
243
|
+
{ hint: "Name one with --workspace <slug>." },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return workspaceSlug(hits[0].workspace);
|
|
247
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { api } from "../client.js";
|
|
2
|
+
import { resolveContext } from "../config.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { json } from "../output.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `zumino api <METHOD> <PATH> [--body JSON]` — the escape hatch.
|
|
8
|
+
*
|
|
9
|
+
* It is not a fallback for a missing feature; it is what keeps the verb layer
|
|
10
|
+
* small enough to be worth learning. Every one of the API's endpoints is
|
|
11
|
+
* reachable here, including ones added after this CLI was installed — which is
|
|
12
|
+
* why `CLI_MIN` on the server moves only for a *breaking* change and not for a
|
|
13
|
+
* new endpoint. An old CLI can always reach a new path.
|
|
14
|
+
*
|
|
15
|
+
* `GET /openapi.json` enumerates everything, and an invalid value is refused
|
|
16
|
+
* with a message naming what was expected.
|
|
17
|
+
*/
|
|
18
|
+
export async function run(args, flags) {
|
|
19
|
+
const [rawMethod, path, ...rest] = args;
|
|
20
|
+
if (!rawMethod || !path) {
|
|
21
|
+
throw new CliError("zumino api <METHOD> <PATH> [--body JSON]", {
|
|
22
|
+
hint: "Paths are below /api/v1, e.g. /queue or /workspaces/acme/items/ONS-14",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const method = rawMethod.toUpperCase();
|
|
26
|
+
if (!/^(GET|POST|PATCH|PUT|DELETE|HEAD)$/.test(method)) {
|
|
27
|
+
throw new CliError(`"${rawMethod}" is not an HTTP method.`);
|
|
28
|
+
}
|
|
29
|
+
if (!path.startsWith("/")) {
|
|
30
|
+
throw new CliError(`Path must start with "/" — got "${path}".`, {
|
|
31
|
+
hint: "It is relative to /api/v1, so /queue rather than /api/v1/queue.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rawBody = flags.body ?? rest.join(" ").trim();
|
|
36
|
+
let body;
|
|
37
|
+
if (rawBody) {
|
|
38
|
+
try {
|
|
39
|
+
body = JSON.parse(rawBody);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new CliError(`--body is not valid JSON: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ctx = resolveContext(flags);
|
|
46
|
+
const res = await api(ctx, method, path, { body });
|
|
47
|
+
if (res === null) return 0;
|
|
48
|
+
json(res);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|