@catalyst-cloud/cli 0.15.6 → 0.16.1
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/CHANGELOG.md +13 -0
- package/README.md +9 -6
- package/dist/args.js +26 -1
- package/dist/ask.js +56 -0
- package/dist/capabilities.js +3 -1
- package/dist/cli.js +10 -5
- package/dist/environment.js +1 -1
- package/dist/execution.js +2 -2
- package/dist/hosts.js +89 -0
- package/dist/legacy.js +12 -2
- package/dist/machine-inventory.js +37 -0
- package/dist/onboard-capacity.js +5 -6
- package/dist/onboard-github-copy.js +2 -0
- package/dist/onboard-github.js +2 -0
- package/dist/onboard-headless.js +2 -2
- package/dist/onboard-next.js +21 -12
- package/dist/onboard-project-cleanup.js +100 -0
- package/dist/onboard-returning.js +172 -0
- package/dist/onboard-runner.js +149 -34
- package/dist/onboard-runtime.js +26 -1
- package/dist/onboard-ui.js +67 -1
- package/dist/onboard-workflow.js +32 -7
- package/dist/onboard.js +9 -2
- package/dist/ready-copy.js +30 -24
- package/dist/ready.js +2 -2
- package/dist/secret.js +2 -2
- package/dist/setup-onboard-copy.js +37 -32
- package/dist/setup.js +4 -0
- package/dist/transport.js +18 -2
- package/package.json +8 -5
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -3
- package/skills/catalyst-onboard/references/connecting-this-machine.md +4 -4
- package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
- package/skills/catalyst-onboard/references/reading-ready.md +14 -14
- package/skills/catalyst-onboard/references/replacing-a-credential.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +5 -5
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -4
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +20 -10
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +50 -27
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +2 -2
- package/skills/what-needs-me/references/raising-a-decision.md +12 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
- package/skills/what-needs-me/scripts/raise.mjs +10 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +3 -2
- package/skills/whats-happening/references/coding-accounts.md +1 -1
- package/skills/whats-happening/references/machines.md +9 -0
- package/skills/whats-happening/references/settings-and-where-they-live.md +8 -8
- package/skills/whats-happening/references/stages-and-mapping.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.1
|
|
4
|
+
|
|
5
|
+
Setup lists your existing projects and lets you finish this computer without choosing a team again.
|
|
6
|
+
|
|
7
|
+
- Keep your saved machine name when setup finds an existing installation, even if its settings file is missing.
|
|
8
|
+
- Check readiness across projects that share a Linear team, with a clear next action when a check cannot finish.
|
|
9
|
+
- Remove recognized older background jobs automatically. Preserve shared data and explain any cleanup that needs attention.
|
|
10
|
+
- Recheck the existing installation before offering a replacement, and distinguish an ended enrollment from one that could not be verified.
|
|
11
|
+
|
|
12
|
+
## 0.16.0
|
|
13
|
+
|
|
14
|
+
The CLI, skills and plugin now share the 0.16 release line with the SDK and installer. The CLI installs the matching Node replica module for local cache commands. Update the CLI and update the Catalyst plugin through your agent's plugin manager.
|
|
15
|
+
|
|
3
16
|
## 0.15.6
|
|
4
17
|
|
|
5
18
|
Setup checks current readiness and waits up to one minute while Catalyst reads repository settings.
|
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ catalyst ready
|
|
|
85
85
|
|
|
86
86
|
`catalyst login` with no key opens a device-code login: it prints a short code and a URL, you approve it in your browser, and this machine is connected as you. On a machine with no browser (a remote box, a container) the code and URL still work — approve them from your phone. The short-lived session refreshes silently afterwards, so you log in about once a year. `npx -p @catalyst-cloud/cli catalyst login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
For a script or an unattended machine, where nobody can approve a code, pass a **personal key** instead. Minting one is a browser step: Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
91
|
CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst login
|
|
@@ -98,7 +98,8 @@ After a workspace owner connects your cloud account's Linear workspace, connect
|
|
|
98
98
|
```sh
|
|
99
99
|
catalyst connections personal linear start
|
|
100
100
|
catalyst connections personal linear status
|
|
101
|
-
# First install the workspace's GitHub App and register its repository
|
|
101
|
+
# First install the workspace's GitHub App and register its repository:
|
|
102
|
+
# catalyst onboard --team <KEY> --repo <owner/name>
|
|
102
103
|
catalyst connections personal github start
|
|
103
104
|
catalyst connections personal github status
|
|
104
105
|
```
|
|
@@ -121,7 +122,7 @@ A keyless session's access token is short-lived and rotates on its own: every re
|
|
|
121
122
|
|
|
122
123
|
`login` does not install skills; the install command above did that. Re-running `login` after a key rotation, or to switch rails, rewrites the config. `catalyst status` prints which cloud account this machine is connected to and as whom, `catalyst ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst --version` prints the package version and its pinned contract range.
|
|
123
124
|
|
|
124
|
-
The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (
|
|
125
|
+
The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (minted by `catalyst onboard --runner`, run by an owner or admin) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
|
|
125
126
|
|
|
126
127
|
## Run and resume setup
|
|
127
128
|
|
|
@@ -161,6 +162,8 @@ Before any network call, the run checks that every input is present. Each missin
|
|
|
161
162
|
| The person's Linear | `linear_personal_grant_missing` | Settings → Connected accounts |
|
|
162
163
|
| The person's GitHub | `github_personal_grant_missing` | Settings → Connected accounts |
|
|
163
164
|
|
|
165
|
+
With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
|
|
166
|
+
|
|
164
167
|
With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
|
|
165
168
|
|
|
166
169
|
With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
|
|
@@ -183,7 +186,7 @@ Local sync is optional. Include it in the plan with `--local-sync` when you need
|
|
|
183
186
|
## Requirements
|
|
184
187
|
|
|
185
188
|
- Node 22.15 or newer (Node 26 works), or bun 1.4 or newer. The bundle uses Node's built-in SQLite module (and, on bun, bun's own `node:sqlite`) for the optional local replica, so there is no native dependency to build; if `better-sqlite3` resolves on the machine it is used instead. `catalyst ready` names the exact reason when the runtime is too old, and `catalyst runtime install` installs a pinned Node under this CLI's own cache — without touching your machine's default Node — if you would rather not upgrade it.
|
|
186
|
-
- Your personal key
|
|
189
|
+
- Your own login: a keyless `catalyst login`, or for an unattended machine a personal key you mint at Settings → API keys. The credential is the only account selector: you never type an account id. If your Linear identity is not matched yet, `login` says so; you match it yourself with `catalyst identity linear set` (an identity someone else already claims needs an admin at Settings → Members), and until then "what needs me" shows everyone's asks.
|
|
187
190
|
- An agent that discovers skills. Claude Code loads the plugin; Codex, Cursor, OpenCode and the rest read the `skills/<name>/SKILL.md` files the `npx skills` installer writes.
|
|
188
191
|
- Bun is optional, only if you prefer `bunx` over `npx` — bun 1.4 or newer, which is when `node:sqlite` arrives; older bun cannot run this CLI at all.
|
|
189
192
|
|
|
@@ -233,7 +236,7 @@ Some of these only read, and some write: a comment, a card move, an ask, a relea
|
|
|
233
236
|
|
|
234
237
|
## What a key cannot see yet
|
|
235
238
|
|
|
236
|
-
Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts
|
|
239
|
+
Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, usage limits, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`, with no command yet). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the account-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-onboard` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value; `env check` checks the TOML syntax and environment variable table in `.catalyst/catalyst.toml` offline; `env migrate [catalyst.env.json]` prints a TOML environment table from a legacy declaration, with all names optional and values omitted. These commands need no login (the `what-this-repo-needs` skill walks through them). Two things it cannot do, and the skills say so by name rather than guess:
|
|
237
240
|
|
|
238
241
|
- Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
|
|
239
242
|
- Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
|
|
@@ -277,7 +280,7 @@ npm uninstall -g @catalyst-cloud/cli
|
|
|
277
280
|
|
|
278
281
|
## If login fails
|
|
279
282
|
|
|
280
|
-
- `catalyst: GET /me failed (401): credential not accepted
|
|
283
|
+
- `catalyst: GET /me failed (401): credential not accepted. Run catalyst login to sign in again; an unattended machine needs a new personal key from Settings → API keys` — the key is stale, mistyped or revoked. Run a keyless `catalyst login`, or on an unattended machine mint a new key, then run `login` again.
|
|
281
284
|
- `catalyst: GET /me failed (403): account-not-operational` — your cloud account is suspended. This is a conversation with your account's admin, not a local fix.
|
|
282
285
|
- `catalyst: could not reach <url>: <detail>` — the machine cannot reach the cloud. The URL is named in the message; check `CATALYST_CLOUD_BASE_URL` or `--base-url`.
|
|
283
286
|
- A line naming two contract versions after `Connected to` — your account serves a contract outside this bundle's `1.x || 2.x` range. The config is written; update the bundle before using the other skills.
|
package/dist/args.js
CHANGED
|
@@ -20,6 +20,7 @@ const GLOBAL_FLAGS = {
|
|
|
20
20
|
};
|
|
21
21
|
/** Per-verb flag tables. A verb absent here accepts only the global flags. */
|
|
22
22
|
export const FLAG_TABLES = {
|
|
23
|
+
hosts: { yes: { value: false, help: "confirm removal without a terminal" } },
|
|
23
24
|
login: {
|
|
24
25
|
"start-replica": {
|
|
25
26
|
value: false,
|
|
@@ -41,6 +42,10 @@ export const FLAG_TABLES = {
|
|
|
41
42
|
},
|
|
42
43
|
install: {},
|
|
43
44
|
onboard: {
|
|
45
|
+
"machine-name": {
|
|
46
|
+
value: true,
|
|
47
|
+
help: "name this self-hosted machine (default: its hostname)",
|
|
48
|
+
},
|
|
44
49
|
verbose: { value: false, help: "show granted permissions for connected steps in interactive setup" },
|
|
45
50
|
team: {
|
|
46
51
|
value: true,
|
|
@@ -280,6 +285,25 @@ export const FLAG_TABLES = {
|
|
|
280
285
|
value: false,
|
|
281
286
|
help: "raise: declare that nothing is blocked",
|
|
282
287
|
},
|
|
288
|
+
"gates-pr": {
|
|
289
|
+
value: true,
|
|
290
|
+
repeat: true,
|
|
291
|
+
help: "raise: a PR whose hold the answer releases (repeatable; needs --released-by)",
|
|
292
|
+
},
|
|
293
|
+
"gates-label": {
|
|
294
|
+
value: true,
|
|
295
|
+
repeat: true,
|
|
296
|
+
help: "raise: the hold label the answer removes: hold or hold:preview (repeatable; default hold)",
|
|
297
|
+
},
|
|
298
|
+
"released-by": {
|
|
299
|
+
value: true,
|
|
300
|
+
repeat: true,
|
|
301
|
+
help: "raise: an option letter whose answer releases the hold (repeatable)",
|
|
302
|
+
},
|
|
303
|
+
"gates-repo": {
|
|
304
|
+
value: true,
|
|
305
|
+
help: "raise: the --gates-pr repository as owner/name (default: the account's only repository)",
|
|
306
|
+
},
|
|
283
307
|
anyone: {
|
|
284
308
|
value: false,
|
|
285
309
|
help: "list: every open ask in the tenant, not only the ones assigned to you",
|
|
@@ -461,6 +485,7 @@ export const FLAG_TABLES = {
|
|
|
461
485
|
},
|
|
462
486
|
};
|
|
463
487
|
export const VERB_USAGE = {
|
|
488
|
+
hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
|
|
464
489
|
setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
|
|
465
490
|
login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
|
|
466
491
|
onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
|
|
@@ -479,7 +504,7 @@ export const VERB_USAGE = {
|
|
|
479
504
|
queue: "queue [--team K] [--json]",
|
|
480
505
|
watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
|
|
481
506
|
write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
|
|
482
|
-
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
|
|
507
|
+
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
|
|
483
508
|
ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
|
|
484
509
|
accounts: "accounts [--json]",
|
|
485
510
|
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
|
package/dist/ask.js
CHANGED
|
@@ -39,6 +39,7 @@ async function raise(args, ctx, doc, api) {
|
|
|
39
39
|
if (options.length > doc.askTemplate.maxLetteredOptions) {
|
|
40
40
|
throw new UsageError(`ask raise takes at most ${doc.askTemplate.maxLetteredOptions} options`);
|
|
41
41
|
}
|
|
42
|
+
const gates = askGates(args, options, doc);
|
|
42
43
|
const blocks = [];
|
|
43
44
|
for (const t of flagList(args, "blocks"))
|
|
44
45
|
blocks.push((await resolveIssue(api, t)).id);
|
|
@@ -55,10 +56,65 @@ async function raise(args, ctx, doc, api) {
|
|
|
55
56
|
body.nothingToBlock = true;
|
|
56
57
|
if (flagString(args, "ask-key"))
|
|
57
58
|
body.askKey = flagString(args, "ask-key");
|
|
59
|
+
if (gates)
|
|
60
|
+
body.gates = gates;
|
|
58
61
|
const result = await postAgent(api, doc, "ask", body);
|
|
59
62
|
ctx.stdout(args.json ? JSON.stringify(result) : `ask raised: ${String(result.identifier ?? result.id ?? "ok")}${blocks.length ? ` (blocks ${blocks.length})` : ""}`);
|
|
60
63
|
return 0;
|
|
61
64
|
}
|
|
65
|
+
/** The hold labels an answered ask may lift; the cloud's ask route refuses any other with a 400. */
|
|
66
|
+
const GATE_LABELS = ["hold", "hold:preview"];
|
|
67
|
+
const OWNER_NAME = /^[^/\s]+\/[^/\s]+$/;
|
|
68
|
+
/** CTC-5044 — the PR hold an answer releases. Absent unless --gates-pr is given, so an ask without it
|
|
69
|
+
* posts exactly the body it always did. Everything the route would refuse (a label outside
|
|
70
|
+
* {@link GATE_LABELS}, a letter that is not one of this ask's options) is refused here first, before
|
|
71
|
+
* any ticket is resolved or anything is posted. Options are lettered A, B, C in the order given. */
|
|
72
|
+
function askGates(args, options, doc) {
|
|
73
|
+
const prs = flagList(args, "gates-pr");
|
|
74
|
+
if (prs.length === 0) {
|
|
75
|
+
for (const f of ["released-by", "gates-label", "gates-repo"]) {
|
|
76
|
+
if (flagList(args, f).length || flagString(args, f))
|
|
77
|
+
throw new UsageError(`ask raise takes --${f} only with --gates-pr <n>`);
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const pr = [...new Set(prs.map((n) => {
|
|
82
|
+
if (!/^[1-9]\d*$/.test(n))
|
|
83
|
+
throw new UsageError(`--gates-pr must be a PR number (got "${n}")`);
|
|
84
|
+
return Number(n);
|
|
85
|
+
}))];
|
|
86
|
+
const labels = [...new Set(flagList(args, "gates-label"))];
|
|
87
|
+
for (const l of labels) {
|
|
88
|
+
if (!GATE_LABELS.includes(l))
|
|
89
|
+
throw new UsageError(`--gates-label ${l} is not a hold an answer can release; use one of: ${GATE_LABELS.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
const released = [...new Set(flagList(args, "released-by").map((l) => l.trim().toUpperCase()))];
|
|
92
|
+
if (released.length === 0)
|
|
93
|
+
throw new UsageError("--gates-pr needs --released-by <letter>: the option whose answer releases the hold");
|
|
94
|
+
const letters = options.map((_, i) => String.fromCharCode(65 + i));
|
|
95
|
+
for (const l of released) {
|
|
96
|
+
if (!letters.includes(l))
|
|
97
|
+
throw new UsageError(`--released-by ${l} is not one of this ask's options (${letters.length ? letters.join(", ") : "it has none; add --option"})`);
|
|
98
|
+
}
|
|
99
|
+
const registered = doc.merge.repositories.map((r) => `${r.owner}/${r.name}`);
|
|
100
|
+
const named = flagString(args, "gates-repo");
|
|
101
|
+
let repo;
|
|
102
|
+
if (named !== undefined) {
|
|
103
|
+
if (!OWNER_NAME.test(named))
|
|
104
|
+
throw new UsageError(`--gates-repo must be owner/name (got "${named}")`);
|
|
105
|
+
const match = registered.find((r) => r.toLowerCase() === named.toLowerCase());
|
|
106
|
+
if (registered.length && !match)
|
|
107
|
+
throw new UsageError(`--gates-repo ${named} is not a repository this account registers (${registered.join(", ")})`);
|
|
108
|
+
repo = match ?? named;
|
|
109
|
+
}
|
|
110
|
+
else if (registered.length === 1) {
|
|
111
|
+
repo = registered[0];
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new UsageError(`--gates-pr needs --gates-repo <owner/name>: this account registers ${registered.length ? registered.join(", ") : "no repository"}`);
|
|
115
|
+
}
|
|
116
|
+
return { repo, pr: pr.length === 1 ? pr[0] : pr, labels: labels.length ? labels : ["hold"], releasedBy: released };
|
|
117
|
+
}
|
|
62
118
|
async function accept(args, ctx, doc, api, rest) {
|
|
63
119
|
const askTicket = rest[0];
|
|
64
120
|
const answer = flagString(args, "answer");
|
package/dist/capabilities.js
CHANGED
|
@@ -5,9 +5,11 @@ const agent = (method, name) => ({ method, path: `/api/v1/agent/${name}` });
|
|
|
5
5
|
* the person, the workspace, the project, the repository, then work. A new verb is a new row here
|
|
6
6
|
* in the same change, or `catalyst capabilities` lies to every guide that reads it. */
|
|
7
7
|
export const CAPABILITIES = [
|
|
8
|
+
{ verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
|
|
9
|
+
{ verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
|
|
8
10
|
// CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
|
|
9
11
|
// reads this flag rather than `setup --help`, which already exits 0 on 0.15.x.
|
|
10
|
-
{ verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true },
|
|
12
|
+
{ verb: "setup", does: "set up Catalyst step by step, or show install.sh's plan, progress and sign-in in this terminal", needs: "member", routes: [], since: "0.15.0", engineProtocol: 1, bootstrapHandoff: true, bootstrapHandoffVersion: 2 },
|
|
11
13
|
{ verb: "onboard", does: "run and resume Catalyst setup with a saved step record", needs: "member", routes: [], since: "0.14.0", bootstrapHandoff: true, headless: true },
|
|
12
14
|
{ verb: "status", does: "say whether this machine is connected, as whom, and to which cloud", needs: "member", routes: [], since: "0.1.0" },
|
|
13
15
|
{ verb: "me", does: "read the person's seat, role and Linear identity", needs: "member", routes: [], since: "0.1.0" },
|
package/dist/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cmdHosts } from "./hosts.js";
|
|
1
2
|
// cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
|
|
2
3
|
// their own modules; this file keeps every export the 0.1 tests and the bin import.
|
|
3
4
|
import { createSetupRenderer, setupBanner, } from "./setup-render.js";
|
|
@@ -79,6 +80,7 @@ export function usageText() {
|
|
|
79
80
|
" catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
|
|
80
81
|
` catalyst ${SETUP_USAGE}`,
|
|
81
82
|
" catalyst status | notice | me | ready | accounts",
|
|
83
|
+
" catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
|
|
82
84
|
" catalyst mcp add|list|remove (vault references only)",
|
|
83
85
|
" catalyst contract [--refresh] [--path <a.b.c>]",
|
|
84
86
|
" catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
|
|
@@ -372,11 +374,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
372
374
|
...(deps.setupApproved
|
|
373
375
|
? { reviewedSetup: true, confirm: async () => true }
|
|
374
376
|
: {}),
|
|
375
|
-
beforeStep: async () => {
|
|
376
|
-
if (args.flags["dry-run"] !== true)
|
|
377
|
-
await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
|
|
378
|
-
},
|
|
379
377
|
...createOnboardRuntime(args, ctx, {
|
|
378
|
+
beforeStep: async () => {
|
|
379
|
+
if (args.flags["dry-run"] !== true)
|
|
380
|
+
await refreshSessionIfShort(ctx, ONBOARD_STEP_MIN_MS);
|
|
381
|
+
},
|
|
380
382
|
ui,
|
|
381
383
|
signinTimeoutMs: deps.onboardSigninTimeoutMs,
|
|
382
384
|
stageSignin: (signal) => {
|
|
@@ -511,6 +513,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
511
513
|
return await cmdLegacy(args, ctx, deps.legacy ?? {});
|
|
512
514
|
case "identity":
|
|
513
515
|
return await cmdIdentity(args, ctx, deps.identity ?? {});
|
|
516
|
+
case "hosts":
|
|
517
|
+
return await cmdHosts(args, ctx, deps.hosts);
|
|
514
518
|
case "connections":
|
|
515
519
|
return await cmdConnections(args, ctx, deps.connections ?? {});
|
|
516
520
|
default:
|
|
@@ -620,6 +624,7 @@ async function runSetup(argv, ctx, deps) {
|
|
|
620
624
|
...(flags.json ? ["--json"] : []),
|
|
621
625
|
...(flags.verbose ? ["--verbose"] : []),
|
|
622
626
|
...(flags.localSync ? ["--local-sync"] : []),
|
|
627
|
+
...(flags.runner === undefined ? [] : [flags.runner ? "--runner" : "--no-runner"]),
|
|
623
628
|
...(flags.yes || flags.json || !terminal ? ["--yes"] : []),
|
|
624
629
|
];
|
|
625
630
|
let ui;
|
|
@@ -726,7 +731,7 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
|
|
|
726
731
|
: ' — your Linear identity is not matched yet, so "what needs me" will show everyone\'s asks until an admin matches it in Settings'}`);
|
|
727
732
|
}
|
|
728
733
|
else {
|
|
729
|
-
ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you.
|
|
734
|
+
ctx.stderr(`[catalyst] this is your Catalyst workspace's shared key (a host credential), not your own. The skills work, but nothing your agent writes will carry your name, and "what needs me" cannot mean you. Run catalyst login to sign in as yourself; for an unattended machine, make a personal key at Settings → API keys and sign in with that.`);
|
|
730
735
|
}
|
|
731
736
|
ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE
|
|
732
737
|
? ""
|
package/dist/environment.js
CHANGED
|
@@ -79,7 +79,7 @@ export function renderRead(r) {
|
|
|
79
79
|
// never a refusal: the secret may be created next, and a repo may supply the name at checkout.
|
|
80
80
|
const unresolved = r.unresolvedReferences ?? [];
|
|
81
81
|
if (unresolved.length > 0) {
|
|
82
|
-
lines.push(`referenced but not set
|
|
82
|
+
lines.push(`referenced but not set in this workspace yet: ${unresolved.join(", ")}. Set them with catalyst var set or catalyst secret set, or a phase that needs one will fail on it`);
|
|
83
83
|
}
|
|
84
84
|
return lines;
|
|
85
85
|
}
|
package/dist/execution.js
CHANGED
|
@@ -51,7 +51,7 @@ export const EXCLUSION_REASONS = {
|
|
|
51
51
|
export const UNKNOWN_REASONS = {
|
|
52
52
|
ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
|
|
53
53
|
ordering_stale: "the dispatch order is stale",
|
|
54
|
-
workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself
|
|
54
|
+
workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself; a workspace owner or admin runs catalyst team map <KEY>, or catalyst team adopt <KEY> to create Catalyst's stages",
|
|
55
55
|
ticket_unknown: "the ticket is not in the mirror",
|
|
56
56
|
dependency_snapshot_unknown: "the dependency snapshot has never completed",
|
|
57
57
|
blocker_unknown: "a blocking relation could not be resolved",
|
|
@@ -373,7 +373,7 @@ export async function cmdAccounts(args, ctx) {
|
|
|
373
373
|
return 0;
|
|
374
374
|
}
|
|
375
375
|
if (accounts.length === 0) {
|
|
376
|
-
ctx.stdout(`No coding accounts are enrolled
|
|
376
|
+
ctx.stdout(`No coding accounts are enrolled in this workspace. A workspace owner or admin adds one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts; there is no command for that yet.`);
|
|
377
377
|
return 0;
|
|
378
378
|
}
|
|
379
379
|
for (const a of accounts)
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { positionals } from "./args.js";
|
|
3
|
+
import { requireConfig } from "./config.js";
|
|
4
|
+
import { CliError, UsageError } from "./errors.js";
|
|
5
|
+
import { apiClient } from "./transport.js";
|
|
6
|
+
import { isMachineInventory, isMachineMutation, } from "./machine-inventory.js";
|
|
7
|
+
import { stdinIsTty } from "./prompt.js";
|
|
8
|
+
async function confirmRemove(name) {
|
|
9
|
+
if (!stdinIsTty())
|
|
10
|
+
return false;
|
|
11
|
+
const prompt = createInterface({
|
|
12
|
+
input: process.stdin,
|
|
13
|
+
output: process.stderr,
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
return /^(y|yes)$/i.test((await prompt.question(`Remove ${name} from this account? It will stop receiving work. [y/N] `)).trim());
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
prompt.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function machineLines(inventory) {
|
|
23
|
+
const lines = inventory.machines.map((m) => `${m.name} | ${m.ownership === "self_hosted" ? "Self-hosted" : "Catalyst's"} | ${m.teams.join(", ")} | ${m.slots} slots, ${m.inUse} in use, ${m.free} free | ${m.status} | last check-in ${m.lastCheckInAtMs === null ? "never" : new Date(m.lastCheckInAtMs).toISOString()}`);
|
|
24
|
+
if (lines.length === 0)
|
|
25
|
+
lines.push("No machines are enrolled for this account.");
|
|
26
|
+
const c = inventory.capacity;
|
|
27
|
+
lines.push(`Live capacity: ${c.slots} slots (${c.selfHostedSlots} self-hosted, ${c.catalystSlots} from Catalyst), ${c.inUse} in use, ${c.free} free. Stale machines are excluded.`);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
export async function cmdHosts(args, ctx, deps = {}) {
|
|
31
|
+
const parts = positionals(args);
|
|
32
|
+
const action = parts[0] ?? "list";
|
|
33
|
+
if (!((action === "list" && parts.length <= 1) ||
|
|
34
|
+
(action === "remove" && parts.length === 2) ||
|
|
35
|
+
(action === "rename" && parts.length === 3)))
|
|
36
|
+
throw new UsageError("hosts takes: [list] | remove <name-or-id> [--yes] | rename <name-or-id> <new-name>");
|
|
37
|
+
if (args.flags.yes === true && action !== "remove")
|
|
38
|
+
throw new UsageError("--yes is only valid with hosts remove");
|
|
39
|
+
if (action === "rename" &&
|
|
40
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(parts[2]))
|
|
41
|
+
throw new UsageError("Use 1 to 63 letters, digits, dots, underscores or hyphens, starting with a letter or digit.");
|
|
42
|
+
const cfg = requireConfig(ctx);
|
|
43
|
+
const client = apiClient(cfg, ctx);
|
|
44
|
+
const listed = await client.getJson("/api/v1/hosts/machines", {
|
|
45
|
+
query: { account: cfg.account },
|
|
46
|
+
});
|
|
47
|
+
if (!isMachineInventory(listed.body) || listed.body.account !== cfg.account)
|
|
48
|
+
throw new CliError("The cloud returned an unreadable machine list.", "shape");
|
|
49
|
+
if (action === "list") {
|
|
50
|
+
if (args.json)
|
|
51
|
+
ctx.stdout(JSON.stringify(listed.body));
|
|
52
|
+
else
|
|
53
|
+
for (const line of machineLines(listed.body))
|
|
54
|
+
ctx.stdout(line);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const matches = listed.body.machines.filter((m) => m.id === parts[1] || m.name === parts[1]);
|
|
58
|
+
if (matches.length !== 1)
|
|
59
|
+
throw new CliError(matches.length === 0
|
|
60
|
+
? "No machine matches that name or ID."
|
|
61
|
+
: "More than one machine has that name. Use its ID.", "machine_not_unique");
|
|
62
|
+
const machine = matches[0];
|
|
63
|
+
if (!(action === "remove" ? machine.removable : machine.renameable))
|
|
64
|
+
throw new CliError("Catalyst manages this cloud machine. Only self-hosted machines can be changed here.", "managed_machine");
|
|
65
|
+
if (action === "remove" &&
|
|
66
|
+
args.flags.yes !== true &&
|
|
67
|
+
!(await (deps.confirm ?? confirmRemove)(machine.name))) {
|
|
68
|
+
ctx.stdout(args.json
|
|
69
|
+
? JSON.stringify({ removed: false, reason: "confirmation_required" })
|
|
70
|
+
: "Machine kept. Use --yes to confirm removal without a terminal.");
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const path = `/api/v1/hosts/${encodeURIComponent(machine.id)}${action === "rename" ? "/name" : ""}?account=${encodeURIComponent(cfg.account)}`;
|
|
74
|
+
const result = action === "remove"
|
|
75
|
+
? await client.deleteJson(path, { accept: [403] })
|
|
76
|
+
: await client.postJson(path, { name: parts[2] }, { accept: [403] });
|
|
77
|
+
if (result.status === 403)
|
|
78
|
+
throw new CliError("Admin access is required to change machines in this account.", "admin_required", 2, 403);
|
|
79
|
+
if (!isMachineMutation(result.body) ||
|
|
80
|
+
result.body.hostId !== machine.id ||
|
|
81
|
+
(action === "rename" && result.body.name !== parts[2]))
|
|
82
|
+
throw new CliError("The cloud did not confirm the machine change.", "shape");
|
|
83
|
+
ctx.stdout(args.json
|
|
84
|
+
? JSON.stringify(result.body)
|
|
85
|
+
: action === "remove"
|
|
86
|
+
? `Removed ${machine.name}.`
|
|
87
|
+
: `Renamed ${machine.name} to ${result.body.name}.`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
package/dist/legacy.js
CHANGED
|
@@ -189,8 +189,18 @@ function remove(item, run, platform, uid) {
|
|
|
189
189
|
const why = platform === "darwin"
|
|
190
190
|
? tool("launchctl", ["bootout", `gui/${uid}/${item.name}`], "launchctl bootout")
|
|
191
191
|
: tool("systemctl", ["--user", "disable", "--now", item.name], "systemctl --user disable --now");
|
|
192
|
-
if (why !== null)
|
|
193
|
-
|
|
192
|
+
if (why !== null) {
|
|
193
|
+
if (platform !== "darwin")
|
|
194
|
+
return why;
|
|
195
|
+
// A nonzero bootout may mean the exact owned service was already unloaded.
|
|
196
|
+
// Prove the scheduler domain works before interpreting its absence response.
|
|
197
|
+
const domain = run("launchctl", ["print", `gui/${uid}`]);
|
|
198
|
+
const service = run("launchctl", ["print", `gui/${uid}/${item.name}`]);
|
|
199
|
+
if (domain.status !== 0 ||
|
|
200
|
+
service.status !== 113 ||
|
|
201
|
+
!/could not find service|could not find specified service/i.test(service.stderr || service.stdout))
|
|
202
|
+
return `${why}. Check launchctl print gui/${uid}/${item.name}, fix the reported scheduler error, then run catalyst legacy --remove.`;
|
|
203
|
+
}
|
|
194
204
|
unlinkSync(item.path);
|
|
195
205
|
return null;
|
|
196
206
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2
|
+
const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3
|
+
function machine(value) {
|
|
4
|
+
return (object(value) &&
|
|
5
|
+
typeof value.id === "string" &&
|
|
6
|
+
typeof value.name === "string" &&
|
|
7
|
+
(value.ownership === "self_hosted" || value.ownership === "catalyst") &&
|
|
8
|
+
Array.isArray(value.teams) &&
|
|
9
|
+
value.teams.every((team) => typeof team === "string") &&
|
|
10
|
+
count(value.slots) &&
|
|
11
|
+
count(value.inUse) &&
|
|
12
|
+
count(value.free) &&
|
|
13
|
+
(value.lastCheckInAtMs === null || count(value.lastCheckInAtMs)) &&
|
|
14
|
+
(value.status === "live" || value.status === "stale") &&
|
|
15
|
+
typeof value.removable === "boolean" &&
|
|
16
|
+
typeof value.renameable === "boolean");
|
|
17
|
+
}
|
|
18
|
+
export function isMachineInventory(value) {
|
|
19
|
+
if (!object(value) ||
|
|
20
|
+
typeof value.account !== "string" ||
|
|
21
|
+
!count(value.readAtMs) ||
|
|
22
|
+
!Array.isArray(value.machines) ||
|
|
23
|
+
!value.machines.every(machine) ||
|
|
24
|
+
!object(value.capacity))
|
|
25
|
+
return false;
|
|
26
|
+
return (count(value.capacity.slots) &&
|
|
27
|
+
count(value.capacity.inUse) &&
|
|
28
|
+
count(value.capacity.free) &&
|
|
29
|
+
count(value.capacity.selfHostedSlots) &&
|
|
30
|
+
count(value.capacity.catalystSlots));
|
|
31
|
+
}
|
|
32
|
+
export function isMachineMutation(value) {
|
|
33
|
+
return (object(value) &&
|
|
34
|
+
value.ok === true &&
|
|
35
|
+
typeof value.hostId === "string" &&
|
|
36
|
+
(value.name === undefined || typeof value.name === "string"));
|
|
37
|
+
}
|
package/dist/onboard-capacity.js
CHANGED
|
@@ -163,7 +163,7 @@ export function onboardCapacityAdapter(input = {}) {
|
|
|
163
163
|
const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
|
|
164
164
|
if (!expected)
|
|
165
165
|
return waiting("capacity_identity_unverified");
|
|
166
|
-
if (!teamId || repos.length
|
|
166
|
+
if (!teamId || repos.length === 0 || repos.some(r => r.teamId !== teamId))
|
|
167
167
|
return waiting("capacity_context_unverified");
|
|
168
168
|
const controller = new AbortController(), signal = external
|
|
169
169
|
? AbortSignal.any([external, controller.signal])
|
|
@@ -191,10 +191,9 @@ export function onboardCapacityAdapter(input = {}) {
|
|
|
191
191
|
const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
|
|
192
192
|
if ("reason" in inventory ||
|
|
193
193
|
!current() ||
|
|
194
|
-
!inventory.repositories.some(
|
|
195
|
-
r.
|
|
196
|
-
r.
|
|
197
|
-
r.name.toLowerCase() === repos[0].name.toLowerCase()))
|
|
194
|
+
!repos.every(selectedRepo => inventory.repositories.some(r => r.repoId === selectedRepo.repoId && r.teamId === teamId &&
|
|
195
|
+
r.owner.toLowerCase() === selectedRepo.owner.toLowerCase() &&
|
|
196
|
+
r.name.toLowerCase() === selectedRepo.name.toLowerCase())))
|
|
198
197
|
return pause("capacity_context_unverified");
|
|
199
198
|
const read = await readExistingOnboardJson(ctx, route, signal);
|
|
200
199
|
if ("reason" in read || !current())
|
|
@@ -202,7 +201,7 @@ export function onboardCapacityAdapter(input = {}) {
|
|
|
202
201
|
if (!(await liveIdentity(ctx, journal, signal)) || !current())
|
|
203
202
|
return pause("capacity_identity_unverified");
|
|
204
203
|
const observed = snapshot(read.body, ctx.now().getTime());
|
|
205
|
-
const bucket = observed?.buckets.find(
|
|
204
|
+
const bucket = observed?.buckets.find(b => b.teams.some(t => t.key === teamKey) && repos.some(r => r.repoId === b.repo)), mapped = bucket?.teams.find((t) => t.key === teamKey);
|
|
206
205
|
if (!observed || !bucket || !mapped)
|
|
207
206
|
return pause("capacity_context_unverified");
|
|
208
207
|
if (!mapped.admission || mapped.remaining === null)
|
package/dist/onboard-github.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { finishOnTheWeb } from "./consent-browser.js";
|
|
2
|
+
import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
2
3
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
3
4
|
import { pollConsent } from "./onboard-consent.js";
|
|
4
5
|
import { GITHUB_PERMISSION, githubInstallationPage, parsePermissions, } from "./onboard-permissions.js";
|
|
@@ -468,6 +469,7 @@ export function githubInstallationAdapter(options) {
|
|
|
468
469
|
reason: "github_installation_admin_required",
|
|
469
470
|
};
|
|
470
471
|
// The signed URL is transient credential material: only the browser receives it.
|
|
472
|
+
ctx.stderr(GITHUB_CONFIRM_ACCESS);
|
|
471
473
|
let browserUnavailable = false;
|
|
472
474
|
try {
|
|
473
475
|
await options.openBrowser(url, signal);
|
package/dist/onboard-headless.js
CHANGED
|
@@ -29,8 +29,8 @@ const pages = (baseUrl) => {
|
|
|
29
29
|
keys: `${web}/settings/api-keys`,
|
|
30
30
|
connections: `${web}/a/account/connections`,
|
|
31
31
|
personal: `${web}/settings/connected-accounts`,
|
|
32
|
-
teams: `${web}/settings/
|
|
33
|
-
repos: `${web}/settings/
|
|
32
|
+
teams: `${web}/settings/projects`,
|
|
33
|
+
repos: `${web}/settings/projects`,
|
|
34
34
|
codingAccounts: `${web}/settings/coding-accounts`,
|
|
35
35
|
};
|
|
36
36
|
};
|