@catalyst-cloud/cli 0.16.27 → 0.16.29
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 +8 -0
- package/README.md +19 -4
- package/dist/args.js +12 -2
- package/dist/cli-version.js +41 -0
- package/dist/cli.js +20 -0
- package/dist/events.js +146 -1
- package/dist/messages.js +47 -0
- package/dist/onboard-next.js +25 -0
- package/dist/onboard-runner-disk.js +345 -0
- package/dist/onboard-runner.js +52 -0
- package/dist/onboard.js +8 -0
- package/dist/ready.js +47 -1
- package/dist/runner.js +19 -2
- package/dist/watch/consumer.js +1 -0
- package/dist/watch.js +2 -1
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +5 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/reading-ready.md +1 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reading-from-the-cloud.md +3 -1
- package/vendor/self-host/darwin-thoughts-custody/manifest.json +2 -2
- package/vendor/self-host/darwin-thoughts-custody/verifier.mjs +90 -31
- package/vendor/self-host/host-disk-sample.py +57 -0
- package/vendor/self-host/launchd/dev.catalystcloud.host-disk.plist +18 -0
- package/vendor/self-host/provenance.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.29
|
|
4
|
+
|
|
5
|
+
The CLI now tells Catalyst Cloud which release you have installed: each request to Catalyst Cloud carries the installed version, and so does `catalyst watch` when it connects. Catalyst Cloud can then tell an out-of-date CLI from a current one. Nothing about your requests changes otherwise. On a Mac self-hosted machine, setup now installs a small agent that measures the Mac's disk, so the machine can take work instead of showing zero free slots. When a machine still shows zero free slots, setup and `catalyst ready` now say why: its disk has not been measured yet, or its free space is below the floor (25 GiB by default), with the numbers and the fix. An updated disk sampler also takes effect straight away: setup reloads the running disk agent whenever the sampler script changes, not only when its schedule file does.
|
|
6
|
+
|
|
7
|
+
## 0.16.28
|
|
8
|
+
|
|
9
|
+
`catalyst events wait-for` can now wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a work session (`--session KEY-123/phase#n`), events addressed to you (`--to me`) or a family of event types (`--type pr.*`). An agent can wait hours for a review or a conflict on its own branch: the wait holds one connection, prints each matching event as one JSON line, and after a dropped connection picks up where it left off without missing or repeating an event. Two new exit codes say why a wait ended without a match. 2 means the cloud refused the wait, for example on a ticket outside your teams, or the wait failed. 3 means `--from-cache` found no local event cache. A wait still exits 1 on a timeout and 4 on an outage, as before, and on a timeout it names the `--after` to resume from. New: `catalyst messages read --ticket KEY-123` prints the messages sent to a ticket on your teams, with each sender and time.
|
|
10
|
+
|
|
3
11
|
## 0.16.27
|
|
4
12
|
|
|
5
13
|
A large workspace's local replica now finishes its first download instead of stopping after 30 seconds: it waits as long as data keeps arriving, and gives up only after 150 seconds with no progress. New commands: `catalyst repo status` lists your repositories and whether each is paused, and `catalyst repo pause|resume` pauses or resumes one (owners and admins). `catalyst runner status|stop|start|uninstall` stops a self-hosted machine taking work, starts it again with the same registration, or removes it and revokes its registration. Setup with local sync off now lists an old local replica's files with their sizes and removes them only after you say yes. Setup with local sync on keeps using the replica you already have. On a Mac where Docker Desktop does not share your home folder, setup now says which setting to change (Settings, then Resources, then File sharing).
|
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ Before any network call, the run checks that every input is present. Each missin
|
|
|
164
164
|
|
|
165
165
|
With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
|
|
166
166
|
|
|
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.
|
|
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. On a Mac, setup also installs the `dev.catalystcloud.host-disk` LaunchAgent, which measures the Mac's free disk space every 10 seconds, because Docker's virtual machine cannot see it. It needs Python 3 from Apple's Command Line Tools or Homebrew. When the machine takes no work because the disk Docker uses has less than 25 GiB free (`CATALYST_HOST_FREE_FLOOR_GIB` changes the amount) or has no fresh measurement, exit 11 says which, with the fix, and `catalyst ready` fails its `runner:disk` check the same way. If setup cannot tell which folder holds your Docker engine's disk image, set `CATALYST_HOST_DISK_PATH` to that folder.
|
|
168
168
|
|
|
169
169
|
### Stopping or removing this machine's runner
|
|
170
170
|
|
|
@@ -175,9 +175,9 @@ Run these on the machine itself:
|
|
|
175
175
|
| `catalyst runner stop` | The machine takes no new work. Work it already started finishes or reaches its deadline. Its registration, settings and checkouts are kept, and `catalyst hosts` shows it as `stopped`. |
|
|
176
176
|
| `catalyst runner start` | Starts it again with the same registration. |
|
|
177
177
|
| `catalyst runner status` | Says whether it is running, stopped or not installed. |
|
|
178
|
-
| `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
|
|
178
|
+
| `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders, and the disk-measuring LaunchAgent setup installed. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
|
|
179
179
|
|
|
180
|
-
Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label
|
|
180
|
+
Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label, a changed unit file or a disk-measuring LaunchAgent that writes somewhere else, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
|
|
181
181
|
|
|
182
182
|
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
183
|
|
|
@@ -246,7 +246,22 @@ CLI and skill reads use the cloud API by default, including on a machine with a
|
|
|
246
246
|
|
|
247
247
|
For a local view, `catalyst replica status` checks the writer, heartbeat and cursor. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent. `replica status --probe` compares its cursor with the cloud head. These diagnostics do not change the default read source, and an absent replica does not make `catalyst ready` fail.
|
|
248
248
|
|
|
249
|
-
`catalyst events tail`, `wait-for`, `query` and `status` read from the cloud by default, with no local file. `events tail` prints one JSON line per new event and stays in the foreground; `events wait-for --ticket ... --timeout ...` prints the first match and exits 0, or exits 1 when none arrives in time; `events query` reads one page of filtered history (`--ticket`, `--type`, `--limit` up to 200, `--order desc|asc`, `--before`, `--after`); `events status` names the cloud head.
|
|
249
|
+
`catalyst events tail`, `wait-for`, `query` and `status` read from the cloud by default, with no local file. `events tail` prints one JSON line per new event and stays in the foreground; `events wait-for --ticket ... --timeout ...` prints the first match and exits 0, or exits 1 when none arrives in time; `events query` reads one page of filtered history (`--ticket`, `--type`, `--limit` up to 200, `--order desc|asc`, `--before`, `--after`); `events status` names the cloud head.
|
|
250
|
+
|
|
251
|
+
`events wait-for` can also wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a phase session (`--session TICKET/phase#n`), events addressed to you (`--to me`), or a family of types (`--type pr.*`). Values of one flag are OR'ed and different flags are AND'ed, and `--repo`, `--ref`, `--pr` and `--session` can repeat. Such a wait holds one connection and prints nothing until a match, so a long `--timeout` such as 14400 costs nothing while it waits. It prints every matching event as one JSON line. After a dropped connection it replays from its cursor, so no event is missed or printed twice.
|
|
252
|
+
|
|
253
|
+
| `wait-for` exit | Meaning |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| 0 | a match, printed on stdout |
|
|
256
|
+
| 1 | none arrived in time; with a scope flag, stderr names the `--after` to resume from |
|
|
257
|
+
| 2 | the cloud refused the wait (for example a ticket outside your teams), or the wait failed; stderr gives the reason |
|
|
258
|
+
| 3 | `--from-cache` and the local event cache is absent or its writer stopped |
|
|
259
|
+
| 4 | the cloud was unreachable for five attempts in a row: an outage, not a timeout |
|
|
260
|
+
| 130 | interrupted |
|
|
261
|
+
|
|
262
|
+
`catalyst messages read --ticket KEY-123 [--since SEQUENCE]` prints the messages sent to a ticket on your teams, with each sender and time, for example after a wait on `--type comms.message.posted` returns.
|
|
263
|
+
|
|
264
|
+
Add `--from-cache` to read this machine's local event cache instead, which pays off when several agents on one machine share one cloud connection. `catalyst events status --from-cache --probe` checks the cache's own cursor against the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
|
|
250
265
|
|
|
251
266
|
The replica is a Node process, not a service: the supported path is the plain command, and `skills/catalyst-onboard/references/local-sync.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
|
|
252
267
|
|
package/dist/args.js
CHANGED
|
@@ -158,11 +158,16 @@ export const FLAG_TABLES = {
|
|
|
158
158
|
value: false,
|
|
159
159
|
help: "status --from-cache: compare the local event cursor with the cloud event head",
|
|
160
160
|
},
|
|
161
|
-
type: { value: true, help: "exact event type" },
|
|
161
|
+
type: { value: true, help: "exact event type, or for wait-for a family such as pr.*" },
|
|
162
162
|
ticket: {
|
|
163
163
|
value: true,
|
|
164
164
|
help: "ticket identifier found in the event",
|
|
165
165
|
},
|
|
166
|
+
repo: { value: true, repeat: true, help: "wait-for: repository as owner/name (repeatable)" },
|
|
167
|
+
ref: { value: true, repeat: true, help: "wait-for: branch as owner/name:branch (repeatable)" },
|
|
168
|
+
pr: { value: true, repeat: true, help: "wait-for: pull request as owner/name#number (repeatable)" },
|
|
169
|
+
session: { value: true, repeat: true, help: "wait-for: phase session as TICKET/phase#attempt (repeatable)" },
|
|
170
|
+
to: { value: true, help: "wait-for: me, for events addressed to this login" },
|
|
166
171
|
after: {
|
|
167
172
|
value: true,
|
|
168
173
|
help: "event sequence to read after (tail and wait-for default to new events only)",
|
|
@@ -478,6 +483,10 @@ export const FLAG_TABLES = {
|
|
|
478
483
|
},
|
|
479
484
|
},
|
|
480
485
|
identity: {},
|
|
486
|
+
messages: {
|
|
487
|
+
ticket: { value: true, help: "read: the ticket whose messages to read" },
|
|
488
|
+
since: { value: true, help: "read: the first message sequence to read (inclusive)" },
|
|
489
|
+
},
|
|
481
490
|
capabilities: {},
|
|
482
491
|
project: {
|
|
483
492
|
team: {
|
|
@@ -564,7 +573,7 @@ export const VERB_USAGE = {
|
|
|
564
573
|
query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--all] [--source replica|api] [--json]",
|
|
565
574
|
replica: 'replica <start [--detach]|stop|status [--probe] [--json]|sql "<select>"|schema [table]> [--db <path>]',
|
|
566
575
|
runtime: "runtime <status [--json]|install|path|uninstall>",
|
|
567
|
-
events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
|
|
576
|
+
events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--repo O/N] [--ref O/N:BRANCH] [--pr O/N#N] [--session T/PHASE#N] [--to me] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
|
|
568
577
|
explain: "explain <ticket> [--history] [--json]",
|
|
569
578
|
history: "history <ticket> [--json]",
|
|
570
579
|
running: "running [--ticket T --phase P] [--json]",
|
|
@@ -582,6 +591,7 @@ export const VERB_USAGE = {
|
|
|
582
591
|
secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--names NAME[,NAME...]] [--rotate NAME]... [--json]",
|
|
583
592
|
var: "var <set NAME|import <file>> [--repo <owner/name>] [--names NAME[,NAME...]] [--json] (plain environment variables; set reads stdin or a hidden prompt)",
|
|
584
593
|
identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
|
|
594
|
+
messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
|
|
585
595
|
capabilities: "capabilities [--json]",
|
|
586
596
|
project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
|
|
587
597
|
repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const HEADER = "x-catalyst-cli-version";
|
|
2
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
3
|
+
/** Public package metadata on the selected cloud API transport. All fetch semantics stay intact. */
|
|
4
|
+
export function reportCliVersion(transport, version, origins) {
|
|
5
|
+
if (version.length > 80 || !VERSION.test(version))
|
|
6
|
+
return transport;
|
|
7
|
+
return (input, init) => {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(input instanceof Request ? input.url : String(input));
|
|
11
|
+
const selected = origins().some(origin => {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(origin).origin === url.origin;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
if (!selected || !(url.pathname.startsWith("/api/v1/") || ["/snapshot", "/changes", "/events"].includes(url.pathname)))
|
|
20
|
+
return transport(input, init);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return transport(input, init);
|
|
24
|
+
}
|
|
25
|
+
return transport(input, { ...init, headers: withVersion(init?.headers ?? (input instanceof Request ? input.headers : undefined), version) });
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function withVersion(headers, version) {
|
|
29
|
+
// A Headers from another realm or library fails instanceof, and Object.entries would read it as
|
|
30
|
+
// empty and drop every header the caller sent. Anything iterable that is not an array is copied.
|
|
31
|
+
if (headers instanceof Headers || (!Array.isArray(headers) && typeof headers?.forEach === "function")) {
|
|
32
|
+
const copy = new Headers(headers);
|
|
33
|
+
copy.set(HEADER, version);
|
|
34
|
+
return copy;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(headers))
|
|
37
|
+
return [...headers.filter(([name]) => name.toLowerCase() !== HEADER), [HEADER, version]];
|
|
38
|
+
const record = Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() !== HEADER));
|
|
39
|
+
record[HEADER] = version;
|
|
40
|
+
return record;
|
|
41
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, openSync, closeSync } from "node:fs";
|
|
|
8
8
|
import { ReadStream as TerminalInput } from "node:tty";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { parseArgs, positionals, verbHelp } from "./args.js";
|
|
11
|
+
import { reportCliVersion } from "./cli-version.js";
|
|
11
12
|
import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
|
|
12
13
|
import { loadContract, pickPath } from "./contract.js";
|
|
13
14
|
import { CliError, MeError, UsageError } from "./errors.js";
|
|
@@ -47,6 +48,7 @@ import { cmdProject } from "./project.js";
|
|
|
47
48
|
import { cmdRepo } from "./repo.js";
|
|
48
49
|
import { cmdLegacy } from "./legacy.js";
|
|
49
50
|
import { cmdIdentity } from "./identity.js";
|
|
51
|
+
import { cmdMessages } from "./messages.js";
|
|
50
52
|
import { cmdConnections } from "./connections.js";
|
|
51
53
|
import { cmdOnboard, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
|
|
52
54
|
import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
|
|
@@ -93,6 +95,7 @@ export function usageText() {
|
|
|
93
95
|
' catalyst replica <start [--detach]|stop|status [--probe]|sql "<select>"|schema [table]>',
|
|
94
96
|
" catalyst runtime <status [--json]|install|path|uninstall> (a pinned Node this CLI manages itself)",
|
|
95
97
|
" catalyst events <tail|wait-for|query|status> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--from-cache]",
|
|
98
|
+
" catalyst events wait-for [--repo O/N] [--ref O/N:BRANCH] [--pr O/N#N] [--session T/PHASE#N] [--to me] [--type pr.*] [--timeout SECONDS] (one connection; nothing printed until a match)",
|
|
96
99
|
" catalyst explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
|
|
97
100
|
" catalyst watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
|
|
98
101
|
" catalyst write <comment|state|label|create|reaction|attachment|session> ...",
|
|
@@ -112,6 +115,7 @@ export function usageText() {
|
|
|
112
115
|
" catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
|
|
113
116
|
" catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
|
|
114
117
|
" catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
|
|
118
|
+
" catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
|
|
115
119
|
" catalyst connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
|
|
116
120
|
"",
|
|
117
121
|
"Every verb takes --help. --json makes the output machine-readable.",
|
|
@@ -277,6 +281,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
277
281
|
command: args.command,
|
|
278
282
|
version: manifest.version,
|
|
279
283
|
});
|
|
284
|
+
ctx = {
|
|
285
|
+
...ctx,
|
|
286
|
+
fetch: reportCliVersion(ctx.fetch, manifest.version, () => {
|
|
287
|
+
let configured;
|
|
288
|
+
try {
|
|
289
|
+
configured = loadConfig(ctx.home)?.baseUrl;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// The command reports its existing config error.
|
|
293
|
+
}
|
|
294
|
+
return [args.baseUrl, ctx.env.CATALYST_CLOUD_BASE_URL, configured, DEFAULT_BASE_URL].filter((origin) => typeof origin === "string");
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
280
297
|
try {
|
|
281
298
|
if (args.command !== "onboard") {
|
|
282
299
|
migrateLegacyCliPath(ctx);
|
|
@@ -535,6 +552,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
535
552
|
return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
|
|
536
553
|
case "identity":
|
|
537
554
|
return await cmdIdentity(args, ctx, deps.identity ?? {});
|
|
555
|
+
case "messages":
|
|
556
|
+
return await cmdMessages(args, ctx, deps.messages ?? {});
|
|
538
557
|
case "hosts":
|
|
539
558
|
return await cmdHosts(args, ctx, deps.hosts);
|
|
540
559
|
case "runner":
|
|
@@ -599,6 +618,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
|
|
|
599
618
|
"environment",
|
|
600
619
|
"connections",
|
|
601
620
|
"identity",
|
|
621
|
+
"messages",
|
|
602
622
|
"mcp",
|
|
603
623
|
"team",
|
|
604
624
|
"capabilities",
|
package/dist/events.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { flagInt, flagString, positionals } from "./args.js";
|
|
1
|
+
import { flagInt, flagList, flagString, positionals } from "./args.js";
|
|
2
2
|
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
3
3
|
import { CliError, UsageError } from "./errors.js";
|
|
4
4
|
import { authStrategyFor } from "./oauth.js";
|
|
@@ -39,6 +39,13 @@ export async function cmdEvents(args, ctx, deps = {}) {
|
|
|
39
39
|
throw new UsageError("events needs a subcommand: tail | wait-for | query | status");
|
|
40
40
|
if (!["tail", "wait-for", "query", "status"].includes(sub))
|
|
41
41
|
throw new UsageError(`unknown events subcommand: ${sub}`);
|
|
42
|
+
// CTC-6650 — a scope key or a type family runs on the SDK's wait; the --type/--ticket path is unchanged.
|
|
43
|
+
const scope = scopeFilter(args);
|
|
44
|
+
if (scope) {
|
|
45
|
+
if (sub !== "wait-for")
|
|
46
|
+
throw new UsageError("--repo, --ref, --pr, --session, --to and a type family such as pr.* work with wait-for only");
|
|
47
|
+
return cmdScopedWait(args, ctx, deps, scope);
|
|
48
|
+
}
|
|
42
49
|
// CTC-4554 — the cloud is the default source; the local event file is the opt-in.
|
|
43
50
|
if (args.flags["from-cache"] !== true) {
|
|
44
51
|
if (flagString(args, "directory") !== undefined)
|
|
@@ -118,6 +125,144 @@ function startingCursor(args, name = "after") {
|
|
|
118
125
|
throw new UsageError(`--${name} must be a non-negative event sequence`);
|
|
119
126
|
return parsed;
|
|
120
127
|
}
|
|
128
|
+
const SCOPE_KEYS = ["repo", "ref", "pr", "session"];
|
|
129
|
+
/** The SDK filter when the wait names a scope key or a type family, or null for the original path. */
|
|
130
|
+
function scopeFilter(args) {
|
|
131
|
+
const filter = {};
|
|
132
|
+
for (const key of SCOPE_KEYS) {
|
|
133
|
+
const values = flagList(args, key);
|
|
134
|
+
if (values.length)
|
|
135
|
+
filter[key] = values;
|
|
136
|
+
}
|
|
137
|
+
const to = flagString(args, "to");
|
|
138
|
+
if (to !== undefined) {
|
|
139
|
+
if (to !== "me")
|
|
140
|
+
throw new UsageError("--to accepts only me");
|
|
141
|
+
filter.to = "me";
|
|
142
|
+
}
|
|
143
|
+
const type = flagString(args, "type");
|
|
144
|
+
if (Object.keys(filter).length === 0 && !type?.endsWith(".*"))
|
|
145
|
+
return null;
|
|
146
|
+
const ticket = flagString(args, "ticket");
|
|
147
|
+
return { ...filter, ...(type ? { type } : {}), ...(ticket ? { ticket } : {}) };
|
|
148
|
+
}
|
|
149
|
+
/** Counts consecutive failed requests, so a wait that cannot reach the cloud ends as an outage. */
|
|
150
|
+
function outageFetch(base, signal, onOutage, now) {
|
|
151
|
+
let failures = 0;
|
|
152
|
+
let sinceMs = 0;
|
|
153
|
+
const trouble = () => {
|
|
154
|
+
if (failures === 0)
|
|
155
|
+
sinceMs = now();
|
|
156
|
+
failures += 1;
|
|
157
|
+
if (failures >= OUTAGE_ATTEMPTS)
|
|
158
|
+
onOutage(failures, sinceMs);
|
|
159
|
+
};
|
|
160
|
+
return async (input, init) => {
|
|
161
|
+
try {
|
|
162
|
+
const response = await base(input, init);
|
|
163
|
+
if (response.status >= 500)
|
|
164
|
+
trouble();
|
|
165
|
+
else
|
|
166
|
+
failures = 0;
|
|
167
|
+
return response;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
// A request the SDK timed out counts; one cancelled because this wait ended does not.
|
|
171
|
+
if (!signal.aborted)
|
|
172
|
+
trouble();
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** `wait-for` with scope keys: the SDK's wait, under the CLI's documented exit codes. */
|
|
178
|
+
async function cmdScopedWait(args, ctx, deps, filter) {
|
|
179
|
+
const cfg = requireConfig(ctx);
|
|
180
|
+
const timeout = flagInt(args, "timeout", 300);
|
|
181
|
+
if (timeout < 0)
|
|
182
|
+
throw new UsageError("--timeout must be zero or more seconds");
|
|
183
|
+
const after = startingCursor(args);
|
|
184
|
+
const fromCache = args.flags["from-cache"] === true;
|
|
185
|
+
if (!fromCache && flagString(args, "directory") !== undefined)
|
|
186
|
+
throw new UsageError("--directory names the local event cache; add --from-cache to read it");
|
|
187
|
+
const sdk = await (deps.loadSdk ?? loadEventsSdk)();
|
|
188
|
+
const { waitForEvents, waitForCachedEvents } = sdk;
|
|
189
|
+
if (!waitForEvents || !waitForCachedEvents)
|
|
190
|
+
throw new CliError("the installed SDK cannot wait on scope keys; upgrade the catalyst CLI", "sdk-upgrade-required");
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const abort = () => controller.abort(deps.signal?.reason);
|
|
193
|
+
if (deps.signal?.aborted)
|
|
194
|
+
abort();
|
|
195
|
+
else
|
|
196
|
+
deps.signal?.addEventListener("abort", abort, { once: true });
|
|
197
|
+
let interrupted = false;
|
|
198
|
+
let outage;
|
|
199
|
+
const removeInterrupt = (deps.onInterrupt ?? processInterrupts)(() => {
|
|
200
|
+
interrupted = true;
|
|
201
|
+
controller.abort(new Error("interrupted"));
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
let result;
|
|
205
|
+
if (fromCache) {
|
|
206
|
+
result = await waitForCachedEvents({
|
|
207
|
+
tenantId: cfg.account,
|
|
208
|
+
directory: flagString(args, "directory") ?? sdk.defaultEventCacheDirectory(cfg.account),
|
|
209
|
+
filter,
|
|
210
|
+
after,
|
|
211
|
+
timeoutMs: timeout * 1_000,
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const auth = authStrategyFor(ctx, cfg);
|
|
217
|
+
if (auth.kind === "cookie")
|
|
218
|
+
throw new CliError("event waits require a token or OAuth session", "events-auth");
|
|
219
|
+
result = await waitForEvents({
|
|
220
|
+
baseUrl: normalizeBaseUrl(cfg.baseUrl),
|
|
221
|
+
auth,
|
|
222
|
+
tenantId: cfg.account,
|
|
223
|
+
filter,
|
|
224
|
+
after,
|
|
225
|
+
timeoutMs: timeout * 1_000,
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
fetch: outageFetch(ctx.fetch, controller.signal, (failures, sinceMs) => {
|
|
228
|
+
if (outage)
|
|
229
|
+
return;
|
|
230
|
+
outage = { failures, sinceMs };
|
|
231
|
+
controller.abort(new Error("cloud unreachable"));
|
|
232
|
+
}, () => ctx.now().getTime()),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const resume = (cursor) => (cursor === null || cursor === undefined ? "" : `; resume with --after ${cursor}`);
|
|
236
|
+
switch (result.outcome) {
|
|
237
|
+
case "matched":
|
|
238
|
+
for (const event of result.events)
|
|
239
|
+
ctx.stdout(JSON.stringify(event));
|
|
240
|
+
return 0;
|
|
241
|
+
case "timeout":
|
|
242
|
+
ctx.stderr(`events: no matching event within ${timeout} s${resume(result.cursor)}`);
|
|
243
|
+
return 1;
|
|
244
|
+
case "aborted":
|
|
245
|
+
if (interrupted)
|
|
246
|
+
return 130;
|
|
247
|
+
if (outage) {
|
|
248
|
+
ctx.stderr(`events: the cloud event service was unreachable for ${outage.failures} attempts over ${Math.round((ctx.now().getTime() - outage.sinceMs) / 1_000)} s; this is an outage, not a timeout${resume(result.cursor)}`);
|
|
249
|
+
return 4;
|
|
250
|
+
}
|
|
251
|
+
return 1;
|
|
252
|
+
case "refused":
|
|
253
|
+
throw new CliError(`events: ${result.source === "filter" ? "the filter was refused" : `the cloud refused the wait${result.status === null ? "" : ` (${result.status})`}`}${result.key ? ` on --${result.key}${result.value === null ? "" : ` ${result.value}`}` : ""}: ${result.reason}`, "events-refused");
|
|
254
|
+
default:
|
|
255
|
+
// The remaining outcome is "failed"; a default keeps every path ending in a return or throw.
|
|
256
|
+
if (result.code === "cache-missing" || result.code === "cache-writer-stopped")
|
|
257
|
+
throw new CliError(`events: ${result.reason}. Start the local writer with: catalyst replica start --detach, or drop --from-cache to read from the cloud`, "events-absent", 3);
|
|
258
|
+
throw new CliError(`events: the wait failed (${result.code}): ${result.reason}${resume(result.resumeFrom ?? result.cursor)}`, "events-failed");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
removeInterrupt();
|
|
263
|
+
deps.signal?.removeEventListener("abort", abort);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
121
266
|
function eventFilter(args) {
|
|
122
267
|
const type = flagString(args, "type");
|
|
123
268
|
const ticket = flagString(args, "ticket")?.toUpperCase();
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { flagString, positionals } from "./args.js";
|
|
2
|
+
import { requireConfig } from "./config.js";
|
|
3
|
+
import { CliError, UsageError } from "./errors.js";
|
|
4
|
+
import { bearerFor } from "./oauth.js";
|
|
5
|
+
import { loadHttpSdk } from "./sdk.js";
|
|
6
|
+
function failure(result) {
|
|
7
|
+
const reason = "reason" in result && typeof result.reason === "string" ? `: ${result.reason}` : "";
|
|
8
|
+
return `messages: ${result.outcome}${"status" in result && result.status ? ` (${result.status})` : ""}${reason}`;
|
|
9
|
+
}
|
|
10
|
+
export async function cmdMessages(args, ctx, deps = {}) {
|
|
11
|
+
const [action, ...extra] = positionals(args);
|
|
12
|
+
if (action !== "read" || extra.length)
|
|
13
|
+
throw new UsageError("messages takes: read --ticket CTC-N [--since SEQUENCE]");
|
|
14
|
+
const ticket = flagString(args, "ticket")?.trim().toUpperCase();
|
|
15
|
+
if (!ticket)
|
|
16
|
+
throw new UsageError("messages read needs --ticket CTC-N");
|
|
17
|
+
const rawSince = flagString(args, "since");
|
|
18
|
+
const since = rawSince === undefined ? undefined : Number(rawSince);
|
|
19
|
+
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0))
|
|
20
|
+
throw new UsageError("--since must be a non-negative message sequence");
|
|
21
|
+
const cfg = requireConfig(ctx);
|
|
22
|
+
const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
|
|
23
|
+
const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
|
|
24
|
+
if (!client.messages)
|
|
25
|
+
throw new CliError("the installed SDK cannot read messages; upgrade the catalyst CLI", "sdk-upgrade-required");
|
|
26
|
+
const result = await client.messages.read({ ticket, ...(since === undefined ? {} : { since }) });
|
|
27
|
+
if (result.outcome !== "ok") {
|
|
28
|
+
if (args.json)
|
|
29
|
+
ctx.stdout(JSON.stringify(result));
|
|
30
|
+
else
|
|
31
|
+
ctx.stderr(failure(result));
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (args.json)
|
|
35
|
+
ctx.stdout(JSON.stringify({ ticket: result.ticket, messages: result.messages, next: result.next }));
|
|
36
|
+
else if (result.messages.length === 0)
|
|
37
|
+
ctx.stdout(`${result.ticket}: no messages${since === undefined ? "" : ` at or after ${since}`}`);
|
|
38
|
+
else
|
|
39
|
+
for (const message of result.messages) {
|
|
40
|
+
ctx.stdout(`#${message.seq} ${message.sentAt} ${message.from.label} (${message.kind})`);
|
|
41
|
+
for (const line of message.body.split("\n"))
|
|
42
|
+
ctx.stdout(` ${line}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.next !== null)
|
|
45
|
+
ctx.stderr(`more: re-run with --since ${result.next}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
package/dist/onboard-next.js
CHANGED
|
@@ -3,6 +3,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
|
3
3
|
import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
|
|
4
4
|
import { githubInstallationPage } from "./onboard-permissions.js";
|
|
5
5
|
import { valuesFacts, valuesFix } from "./ready-copy.js";
|
|
6
|
+
import { DEFAULT_HOST_FREE_FLOOR_GIB, isRunnerDiskUnmeasured, runnerDiskCopy } from "./onboard-runner-disk.js";
|
|
6
7
|
/** What a person reads for a step that is not done. Each line ends in an action they can take. */
|
|
7
8
|
const REASONS = {
|
|
8
9
|
legacy_cleanup_failed: "An earlier Catalyst installation could not be removed. Run catalyst legacy --remove to see the exact item and its blocker, fix that blocker, then run catalyst setup again. Shared data folders are kept.",
|
|
@@ -158,6 +159,10 @@ const REASONS = {
|
|
|
158
159
|
runner_compose_not_running: "The runner's supervisor is not running. Run docker compose -p catalyst-host logs supervisor to see why, then run catalyst onboard again.",
|
|
159
160
|
runner_enrollment_unverified: "The runner started but has not enrolled yet. Run docker compose -p catalyst-host logs supervisor to see why, or run catalyst onboard again in a minute.",
|
|
160
161
|
runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
|
|
162
|
+
runner_host_disk_sampler_python_missing: "Setup needs Python 3 to measure this Mac's disk for the runner. Install Apple's Command Line Tools with xcode-select --install, or python3 from Homebrew, then run catalyst setup --runner.",
|
|
163
|
+
runner_host_disk_location_unverified: "Setup could not tell which disk holds your Docker engine's virtual machine, so it cannot measure this Mac's free space for the runner. Set CATALYST_HOST_DISK_PATH to that folder (for a relocated Docker Desktop disk image, the folder chosen in Settings, Resources, Advanced), then run catalyst setup --runner.",
|
|
164
|
+
runner_host_disk_sampler_failed: "Setup could not start the dev.catalystcloud.host-disk LaunchAgent that measures this Mac's disk, so the runner will advertise zero free slots. Run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk, or user/$(id -u)/dev.catalystcloud.host-disk when no one is logged in at the console, to see why, then run catalyst setup --runner.",
|
|
165
|
+
runner_host_disk_sampler_not_repeating: "Setup loaded the dev.catalystcloud.host-disk LaunchAgent, but launchd did not keep it measuring this Mac's disk: it published no new sample within 25 seconds, so the runner will advertise zero free slots. launchd can hold back an agent loaded from an SSH session. Log in to this Mac's desktop, at the console or over Screen Sharing, and run catalyst setup --runner in Terminal there. To see what launchd did, run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk and read its runs and pended lines.",
|
|
161
166
|
runner_capability_pending: "The runner enrolled but has not reported its capacity yet. Run catalyst onboard again in a minute.",
|
|
162
167
|
runner_admission_unverified: "Setup could not read whether the team's pool admits runner hosts. Run catalyst onboard again to retry.",
|
|
163
168
|
github_app_permissions_outdated: `The existing GitHub App installation needs updated permissions accepted. ${GITHUB_CONFIRM_ACCESS} An organization owner on GitHub opens the App's installation page, chooses Review permissions, then Accept new permissions. Run catalyst onboard afterward.`,
|
|
@@ -454,6 +459,26 @@ export function onboardReasonText(step, context = {}) {
|
|
|
454
459
|
: "on the web app's Account API keys page";
|
|
455
460
|
return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator runs catalyst onboard --runner on this machine; it mints the key and stores it for the runner. If their cloud cannot mint one yet, they create one with the mirror:read, mirror:write and mirror:feed scopes ${where}, save it in a file only they can read, and run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
|
|
456
461
|
}
|
|
462
|
+
if (reason === "runner_host_disk_sampler_conflict") {
|
|
463
|
+
// Both settings are needed: a custom directory turns off setup's sampler, and the sample file
|
|
464
|
+
// name is then empty unless it is set too.
|
|
465
|
+
const safe = (value) => typeof value === "string" && /^\/[^\x00-\x1f'"]{0,500}$/.test(value) ? value : null;
|
|
466
|
+
const dir = safe(step.evidence?.sampleDir) ?? "<directory the other agent writes to>";
|
|
467
|
+
const file = safe(step.evidence?.sampleFile) ?? "/var/lib/catalyst/host-disk/<its file name>";
|
|
468
|
+
return `Another dev.catalystcloud.host-disk LaunchAgent on this Mac writes its disk sample somewhere else, so setup left it alone and the runner has no disk measurement. To use that agent's sample, set both CATALYST_HOST_DISK_SAMPLE_DIR=${dir} and CATALYST_HOST_DISK_SAMPLE_FILE=${file}, then run catalyst setup --runner; the directory alone leaves the runner without a sample file. Or remove the other agent: run launchctl bootout gui/$(id -u)/dev.catalystcloud.host-disk and launchctl bootout user/$(id -u)/dev.catalystcloud.host-disk (one of them reports it is not loaded), delete ~/Library/LaunchAgents/dev.catalystcloud.host-disk.plist, then run catalyst setup --runner.`;
|
|
469
|
+
}
|
|
470
|
+
if (reason === "runner_host_disk_below_floor" || reason === "runner_host_disk_unmeasured") {
|
|
471
|
+
const e = step.evidence ?? {};
|
|
472
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
473
|
+
const floorGiB = num(e.floorGiB) ?? DEFAULT_HOST_FREE_FLOOR_GIB, availableGiB = num(e.availableGiB);
|
|
474
|
+
if (reason === "runner_host_disk_below_floor" && availableGiB === null)
|
|
475
|
+
return `The runner advertises zero free slots: the disk it measures has less than ${floorGiB} GiB free. Free disk space, then run catalyst ready to see how much is free.`;
|
|
476
|
+
const detail = typeof e.diskDetail === "string" && isRunnerDiskUnmeasured(e.diskDetail) ? e.diskDetail : "physical_host_disk_sample_required";
|
|
477
|
+
const copy = runnerDiskCopy(reason === "runner_host_disk_below_floor" && availableGiB !== null
|
|
478
|
+
? { state: "below_floor", availableGiB, floorGiB }
|
|
479
|
+
: { state: "unmeasured", detail, floorGiB }, e.vm === true);
|
|
480
|
+
return `${copy.line} ${copy.fix}`;
|
|
481
|
+
}
|
|
457
482
|
if (reason === "runner_host_not_ready") {
|
|
458
483
|
const failing = step.evidence?.failing;
|
|
459
484
|
const checks = typeof failing === "string" && /^[a-z0-9.,_-]{1,500}$/.test(failing)
|