@catalyst-cloud/cli 0.16.30 → 0.16.31

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.31
4
+
5
+ New: `catalyst update` is one release check for everything Catalyst installed on this machine. `catalyst update --check` lists each component (the runner images, the CLI, the Cloud and Dev skills, the Claude plugin form of the Cloud skills, the local replica and each registered project's SDK) with its installed and approved versions, and `catalyst update` moves them to the approved release. `catalyst update policy <component> on|off` holds or releases one component. `catalyst runner update --check` and `catalyst runner auto-update on|off` still work and now cover the same components. The CLI updates itself without breaking the command that is running. If that update fails, the report says so, keeps you on the version you have, and tries again at the next release check. Copied skills never move back to an older version. A running local replica comes back on the new CLI with its synced data. When it cannot, `catalyst replica status` says why and where the data is kept. The Claude plugin moves only to the approved version, and after a move the report tells you to run `/reload-plugins` or restart Claude Code. `catalyst update --json` has a new field, `approvedSkills`, naming the version, repository and full commit each skills pack was approved at. It is `null` when no approval applies.
6
+
7
+ A busy self-hosted machine no longer waits to go idle before it takes a release. When the new release can read the work records the current one keeps, `catalyst runner update` restarts the machine's supervisor with its running work left in place. Otherwise it waits for an idle machine, as before. An old runner image that running work still used is removed by a later update.
8
+
3
9
  ## 0.16.30
4
10
 
5
11
  An agent seat can now hold a ticket while it works on it. `catalyst lease take <ticket> --agent <seat>` takes the ticket for that seat for a set time (`--ttl <minutes>`). `catalyst lease renew <ticket>` keeps it, and each renewal names one piece of progress: a commit (`--commit`), an artifact (`--artifact`), a finding (`--finding`) or a pull request (`--pr`). `catalyst lease release <ticket>` hands it back, and `catalyst lease show <ticket>` prints who holds it. A refused renewal, for example one that names no new progress, prints the reason and exits 1. A renewal or release refused because the lease already lapsed, or because another seat took the ticket, forgets the lease this machine stored. The machine stores only the seat name and the lease generation, in a file only you can read. New: `catalyst work available` lists, per team, the tickets nobody holds, with the last holder and their progress, and the open pull requests no live lease covers, drafts included. Both commands use your login. Setup run unattended now says what it skipped and names the exact flags each skipped choice needs. The skill references now say how to see whether a repository is paused, and who can pause or resume it.
package/README.md CHANGED
@@ -42,7 +42,7 @@ The two packs have different jobs and independent versions. Omit `-g` for a proj
42
42
 
43
43
  <details><summary><strong>Alternative for Claude Code: the plugin marketplace</strong></summary>
44
44
 
45
- The plugin installs this pack's same `skills/` tree as a managed bundle that updates when we ship. It needs a GitHub SSH key, and it does not load into the session you are already in — run `/reload-plugins` or restart afterwards. Pick one rail for this pack; installing both leaves you with every skill twice. The development pack has its own optional Claude plugin, `catalyst-dev@catalyst-dev-skills`.
45
+ The plugin installs this pack's same `skills/` tree as a managed bundle, and `catalyst update` moves it to the version your workspace approved (see [Updating](#updating)). It needs a GitHub SSH key, and it does not load into the session you are already in — run `/reload-plugins` or restart afterwards. Pick one rail for this pack; installing both leaves you with every skill twice. The development pack has its own optional Claude plugin, `catalyst-dev@catalyst-dev-skills`.
46
46
 
47
47
  ```
48
48
  /plugin marketplace add coalesce-labs/catalyst-cloud-skills
@@ -298,13 +298,16 @@ The package pins the contract range `1.x || 2.x`, recorded in `package.json` und
298
298
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
299
299
  - `~/.config/catalyst-cloud/contract.json`, your account's cached contract.
300
300
  - `~/.config/catalyst-cloud/published.json`, the cached answer to "what is the newest release" — no credential in it.
301
+ - After `catalyst update` moves the CLI: `~/.config/catalyst-cloud/cli-runtimes/`, one directory per CLI version it installed and `active.json` naming the one your `catalyst` command runs.
301
302
  - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid`, `.writer.lock` and `.writer.state` sidecars, `$XDG_STATE_HOME/catalyst/events/<tenant>/backbone/` (or the home-directory fallback) with bounded daily event segments, and `~/.config/catalyst-cloud/watch-cursor.json`.
302
303
 
303
304
  The skill files themselves are written by whichever install command you ran, in that tool's own location. Your personal key goes into that one config file and nowhere else.
304
305
 
305
306
  ## Updating
306
307
 
307
- A plugin install updates when we ship. Skills copied by `npx skills add` do not. After the source and destination check in Install, re-run the Cloud add command to refresh existing skills and pick up new ones. Update the CLI with `npm install -g @catalyst-cloud/cli@latest && catalyst login` — the re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx -p @catalyst-cloud/cli@latest catalyst login`. The next `catalyst` run prints a one-line notice:
308
+ `catalyst update --check` lists each installed component beside the version your workspace approved, says which one is behind and why, and changes nothing. Add `--project <absolute path>` to include a project's SDK; the report gives the one command that moves it, `catalyst update sdk --project <path>`. A project's files change only when you run that command, or after you turn its SDK updates on with `catalyst update policy sdk on --project <path>`. `catalyst update` moves the runner images, the CLI, the Cloud skills (copied on this machine or installed as the Claude plugin), and a running local replica to the approved versions. The new CLI is installed beside the old one, and your `catalyst` command runs it from then on, at the same path. A running replica restarts on it and keeps its synced data; a stopped one stays stopped. The Claude plugin moves through Claude Code, and only when its marketplace serves the approved version; run `/reload-plugins` or restart Claude Code afterwards. When the release check cannot find the `claude` command, it changes nothing and prints the one command that moves the plugin. The development skills are not on the approved release yet: the report lists their version as unknown and gives no command for them, so update them the way you installed them. On a machine whose runner checks for releases every 15 minutes (`catalyst runner auto-update on`), that check moves the same components by itself. Otherwise they move when you run `catalyst update`. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on. Add `--json` for a machine-readable report.
309
+
310
+ A CLI older than `catalyst update` cannot move itself: install it once with `npm install -g @catalyst-cloud/cli@latest && catalyst login`. The re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx -p @catalyst-cloud/cli@latest catalyst login`. The next `catalyst` run prints a one-line notice:
308
311
 
309
312
  ```
310
313
  [catalyst] updated 0.1.1 → 0.2.0: <that version's CHANGELOG.md summary> · update with: npm install -g @catalyst-cloud/cli@latest && catalyst login
@@ -312,7 +315,7 @@ A plugin install updates when we ship. Skills copied by `npx skills add` do not.
312
315
 
313
316
  A `customer.json` written by an older bundle is still read unchanged; it gains the CLI path and the cached contract the next time you run `catalyst login`.
314
317
 
315
- `catalyst ready` also says when either the installed skills or the CLI is behind the latest publish, naming both versions and the command for each; it is a note, never a failure, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup.
318
+ `catalyst ready` also says when either the installed skills or the CLI is behind, naming both versions and the command for each. It compares them with the approved release this machine last read, and only a machine that has not read one yet compares them with the latest publish. It is a note, never a failure, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup.
316
319
 
317
320
  ## Uninstalling
318
321
 
@@ -325,6 +328,7 @@ catalyst replica stop
325
328
  rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/published.json ~/.config/catalyst-cloud/watch-cursor.json
326
329
  rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock ~/.config/catalyst-cloud/replica.db.writer.state
327
330
  rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
331
+ rm -rf ~/.config/catalyst-cloud/cli-runtimes
328
332
  npm uninstall -g @catalyst-cloud/cli
329
333
  ```
330
334
 
package/bin/launch.js CHANGED
@@ -32,6 +32,7 @@
32
32
  // scripts spawn this CLI constantly, and doubling process startup on every call is a real cost.
33
33
  // • ambient unsupported, no pin -> print the verdict and the ONE command; exit 1, never a raw
34
34
  // module-resolution error.
35
+ import { basename } from "node:path";
35
36
  import { fileURLToPath } from "node:url";
36
37
 
37
38
  /** The one stderr line the deprecated name prints in addition to what the program prints. */
@@ -52,6 +53,7 @@ export const DEPRECATED_NAME_LINE =
52
53
  export async function launch(invokedAs, binUrl) {
53
54
  const argv = process.argv.slice(2);
54
55
  const deprecated = invokedAs === "catalyst-skills";
56
+ if (argv[0] !== "runtime" && (await runSelectedVersion(invokedAs, binUrl))) return;
55
57
  const handled = argv[0] === "runtime" ? false : await preflight(invokedAs, binUrl, argv, deprecated);
56
58
  if (handled) return;
57
59
  const noticeFirst = deprecated && process.stderr.isTTY === true;
@@ -66,6 +68,32 @@ export async function launch(invokedAs, binUrl) {
66
68
  });
67
69
  }
68
70
 
71
+ /**
72
+ * CTC-6708 — a release check installs the approved CLI in its own directory and records it as the
73
+ * selected version (dist/update-cli.js). This command stays where npm put it, so scheduled jobs and
74
+ * saved launcher paths keep working: when the selected version is newer than its own, it loads that
75
+ * version's bin in this same process (no second process, so a pid, a signal and an exit code are the
76
+ * selected version's own). The selected version's launcher finds nothing newer and runs. Any doubt
77
+ * (no record, an unreadable one, a directory that does not hold that version) runs this version, as
78
+ * before. A release check that runs a staged version to check it sets CATALYST_CLI_ACTIVE_RUNTIME
79
+ * to that bin's path, so that run never selects. Returns true when it already handled the invocation.
80
+ */
81
+ async function runSelectedVersion(invokedAs, binUrl) {
82
+ if (process.env.CATALYST_CLI_ACTIVE_RUNTIME === fileURLToPath(binUrl)) return false;
83
+ const home = process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/";
84
+ let bin = null;
85
+ try {
86
+ const [updateCli, config] = await Promise.all([import("../dist/update-cli.js"), import("../dist/config.js")]);
87
+ bin = updateCli.activeRuntimeBin(home, config.readManifest().version, basename(fileURLToPath(binUrl)));
88
+ } catch {
89
+ return false;
90
+ }
91
+ if (!bin) return false;
92
+ const { pathToFileURL } = await import("node:url");
93
+ await import(pathToFileURL(bin).href);
94
+ return true;
95
+ }
96
+
69
97
  /** Returns true when it already handled (and exited) the invocation. */
70
98
  async function preflight(invokedAs, binUrl, args, deprecated) {
71
99
  const policy = process.env.CATALYST_SKILLS_RUNTIME ?? "auto";
@@ -0,0 +1,210 @@
1
+ // approved-release.ts — the release Catalyst approved for every component an install keeps, read
2
+ // once per release check (CTC-6708). The approved record names each release-train member's exact
3
+ // version together, so the CLI's "newest" answer comes from it, never from npm's `latest` tag.
4
+ //
5
+ // Two transports carry the same record:
6
+ // - a signed-in CLI reads the member route `GET /api/v1/releases/approved`;
7
+ // - an enrolled machine's desired-release answer (`GET /api/v1/hosts/desired-release`, read inside
8
+ // the supervisor container by runner-update.ts) carries it as `release.components`, beside the
9
+ // digest-pinned images it already names. The host credential never leaves Docker.
10
+ //
11
+ // Parsing is strict: exact stable versions only (no tags, ranges or pre-releases), every version on
12
+ // the record's declared train line, skills packs pinned to one of two known repositories at a full
13
+ // commit. A record on another train is reported and never applied: automatic moves stay on the
14
+ // MAJOR.MINOR line this CLI ships on. The last record read is cached for diagnostics only; a failed
15
+ // read never applies a cached record.
16
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { configDirFor, loadConfig, readManifest } from "./config.js";
19
+ import { CliError, MeError } from "./errors.js";
20
+ import { isRecord, writePrivateAtomic } from "./runner-update.js";
21
+ import { apiClient } from "./transport.js";
22
+ export const APPROVED_RELEASE_PATH = "/api/v1/releases/approved";
23
+ export const APPROVED_RELEASE_CACHE_FILE = "approved-release.json";
24
+ /** Train members the approved record may name. Only some are installed on a customer's machine;
25
+ * the rest are inventory metadata and never something this CLI installs. */
26
+ export const RELEASE_MEMBERS = [
27
+ "cli",
28
+ "forwarder",
29
+ "plugin",
30
+ "sdk",
31
+ "sdk-replica-node",
32
+ "sdk-replica-browser",
33
+ "schema",
34
+ "replicate",
35
+ "read-model",
36
+ "design",
37
+ "installer",
38
+ ];
39
+ export const SKILL_PACKS = ["cloud", "dev"];
40
+ /** The only repository each pack may come from. A record naming another is refused whole. */
41
+ export const SKILL_PACK_REPOSITORIES = {
42
+ cloud: "coalesce-labs/catalyst-cloud-skills",
43
+ dev: "coalesce-labs/catalyst-dev-skills",
44
+ };
45
+ const STABLE_VERSION = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
46
+ const RELEASE_LINE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
47
+ const COMMIT = /^[0-9a-f]{40}$/;
48
+ const APPROVAL_ID = /^[A-Za-z0-9._:-]{1,128}$/;
49
+ const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
50
+ /** An exact stable version, or null. Unlike skills.ts's lenient `semverOlder`, this is the authority
51
+ * check: `0.16.28` passes; `latest`, `^0.16.28`, `0.16.28-rc.1`, `v0.16.28` and `0.16` do not. */
52
+ export function exactVersion(value) {
53
+ return typeof value === "string" && STABLE_VERSION.test(value) ? value : null;
54
+ }
55
+ /** MAJOR.MINOR of an exact version. */
56
+ export function trainOf(version) {
57
+ return version.split(".").slice(0, 2).join(".");
58
+ }
59
+ /** Compare two exact versions: negative when `a` is older, 0 when equal, positive when newer. */
60
+ export function compareVersions(a, b) {
61
+ const pa = a.split(".").map(Number);
62
+ const pb = b.split(".").map(Number);
63
+ for (let i = 0; i < 3; i++) {
64
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
65
+ if (d !== 0)
66
+ return d;
67
+ }
68
+ return 0;
69
+ }
70
+ /** The train this CLI ships on; automatic updates never leave it. */
71
+ export function supportedReleaseLine() {
72
+ return trainOf(readManifest().version);
73
+ }
74
+ /** Parse the approved record from an unknown body. Any malformed field refuses the whole record. */
75
+ export function parseApprovedRelease(input) {
76
+ const invalid = { ok: false, reason: "response_invalid" };
77
+ if (!isRecord(input) || input.schemaVersion !== 1)
78
+ return invalid;
79
+ if (typeof input.approvalId !== "string" || !APPROVAL_ID.test(input.approvalId))
80
+ return invalid;
81
+ if (!posInt(input.generation) || !posInt(input.approvedAtMs))
82
+ return invalid;
83
+ if (typeof input.releaseLine !== "string" || !RELEASE_LINE.test(input.releaseLine))
84
+ return invalid;
85
+ const releaseLine = input.releaseLine;
86
+ if (!isRecord(input.members))
87
+ return invalid;
88
+ const members = {};
89
+ for (const member of RELEASE_MEMBERS) {
90
+ const value = input.members[member];
91
+ if (value === undefined)
92
+ continue;
93
+ const version = exactVersion(value);
94
+ if (version === null || trainOf(version) !== releaseLine)
95
+ return invalid;
96
+ members[member] = version;
97
+ }
98
+ if (members.cli === undefined)
99
+ return invalid;
100
+ const skills = {};
101
+ if (input.skills !== undefined) {
102
+ if (!isRecord(input.skills))
103
+ return invalid;
104
+ for (const pack of SKILL_PACKS) {
105
+ const value = input.skills[pack];
106
+ if (value === undefined)
107
+ continue;
108
+ if (!isRecord(value))
109
+ return invalid;
110
+ const version = exactVersion(value.version);
111
+ if (version === null || trainOf(version) !== releaseLine)
112
+ return invalid;
113
+ if (value.repository !== SKILL_PACK_REPOSITORIES[pack])
114
+ return invalid;
115
+ if (typeof value.commit !== "string" || !COMMIT.test(value.commit))
116
+ return invalid;
117
+ skills[pack] = { version, repository: value.repository, commit: value.commit };
118
+ }
119
+ }
120
+ return {
121
+ ok: true,
122
+ release: { approvalId: input.approvalId, generation: input.generation, approvedAtMs: input.approvedAtMs, releaseLine, members, skills },
123
+ };
124
+ }
125
+ /** Read the member route with this machine's sign-in. Never throws; every failure is a reason. */
126
+ export async function readMemberApproval(ctx) {
127
+ let cfg;
128
+ try {
129
+ cfg = loadConfig(ctx.home);
130
+ }
131
+ catch {
132
+ return { kind: "unavailable", reason: "signed_out" };
133
+ }
134
+ if (!cfg)
135
+ return { kind: "unavailable", reason: "signed_out" };
136
+ try {
137
+ const res = await apiClient(cfg, ctx).getJson(APPROVED_RELEASE_PATH, { accept: [404] });
138
+ if (res.status === 404)
139
+ return { kind: "unavailable", reason: "not_provided" };
140
+ const body = isRecord(res.body) && res.body.ok === true ? res.body.release : res.body;
141
+ const parsed = parseApprovedRelease(body);
142
+ return parsed.ok ? { kind: "approved", release: parsed.release, source: "network" } : { kind: "unavailable", reason: parsed.reason };
143
+ }
144
+ catch (err) {
145
+ if (err instanceof MeError && err.kind === "network")
146
+ return { kind: "unavailable", reason: "unreachable" };
147
+ if (err instanceof CliError && (err.status === 401 || err.status === 403))
148
+ return { kind: "unavailable", reason: "credential_refused" };
149
+ if (err instanceof MeError && err.kind === "shape")
150
+ return { kind: "unavailable", reason: "response_invalid" };
151
+ return { kind: "unavailable", reason: "unreachable" };
152
+ }
153
+ }
154
+ /** The component record a host's desired-release answer carries, from the same read the image
155
+ * update used. A legacy answer with images only reads `not_provided`; a record whose generation is
156
+ * not the image generation reads `envelope_mismatch`, so the two halves never mix snapshots. */
157
+ export function hostApproval(read) {
158
+ if (read.kind === "no_credential")
159
+ return { kind: "unavailable", reason: "host_unreadable" };
160
+ if (read.kind === "unreachable")
161
+ return { kind: "unavailable", reason: "unreachable" };
162
+ if (read.status === 401 || read.status === 403)
163
+ return { kind: "unavailable", reason: "credential_refused" };
164
+ // 404 and 503 are the cloud saying it has no approved release to name; anything else failed.
165
+ if (read.status === 404 || read.status === 503)
166
+ return { kind: "unavailable", reason: "not_provided" };
167
+ if (read.status !== 200)
168
+ return { kind: "unavailable", reason: "unreachable" };
169
+ if (!isRecord(read.body) || read.body.ok !== true || !isRecord(read.body.release))
170
+ return { kind: "unavailable", reason: "response_invalid" };
171
+ const release = read.body.release;
172
+ if (release.components === undefined)
173
+ return { kind: "unavailable", reason: "not_provided" };
174
+ const parsed = parseApprovedRelease(release.components);
175
+ if (!parsed.ok)
176
+ return { kind: "unavailable", reason: parsed.reason };
177
+ if (parsed.release.generation !== release.generation)
178
+ return { kind: "unavailable", reason: "envelope_mismatch" };
179
+ return { kind: "approved", release: parsed.release, source: "host" };
180
+ }
181
+ export function approvalCachePathFor(home) {
182
+ return join(configDirFor(home), APPROVED_RELEASE_CACHE_FILE);
183
+ }
184
+ /** The last approved record an applying check read, for an offline diagnostic. Never applied. */
185
+ export function readApprovalCache(home) {
186
+ const path = approvalCachePathFor(home);
187
+ if (!existsSync(path))
188
+ return null;
189
+ try {
190
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
191
+ if (!isRecord(parsed) || !posInt(parsed.observedAtMs))
192
+ return null;
193
+ const release = parseApprovedRelease(parsed.release);
194
+ return release.ok ? { observedAtMs: parsed.observedAtMs, release: release.release } : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** Best effort: a read-only home keeps working without its diagnostic cache. */
201
+ export function writeApprovalCache(home, cache) {
202
+ try {
203
+ const path = approvalCachePathFor(home);
204
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
205
+ writePrivateAtomic(path, `${JSON.stringify({ observedAtMs: cache.observedAtMs, release: { schemaVersion: 1, ...cache.release } }, null, 2)}\n`);
206
+ }
207
+ catch {
208
+ // the diagnostic cache is optional
209
+ }
210
+ }
package/dist/args.js CHANGED
@@ -29,6 +29,11 @@ export const FLAG_TABLES = {
29
29
  yes: { value: false, help: "uninstall: confirm without a terminal" },
30
30
  "delete-checkouts": { value: false, help: "uninstall: also delete the repository and thoughts checkouts" },
31
31
  },
32
+ update: {
33
+ check: { value: false, help: "read the approved release and list every component against it, without changing anything" },
34
+ project: { value: true, help: "also check this project's SDK (an absolute path); with policy sdk, the project the setting is for" },
35
+ dir: { value: true, help: "the runner directory (default: the one setup created)" },
36
+ },
32
37
  login: {
33
38
  "start-replica": {
34
39
  value: false,
@@ -575,6 +580,7 @@ export const FLAG_TABLES = {
575
580
  export const VERB_USAGE = {
576
581
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
577
582
  runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--json]",
583
+ update: "update [--check] [--project <absolute path>] [--dir <runner directory>] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path> [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
578
584
  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)",
579
585
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
580
586
  onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--remove-local-data] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
@@ -8,6 +8,9 @@ export const CAPABILITIES = [
8
8
  { verb: "hosts", does: "list this account's machines, current use and live capacity", needs: "member", routes: [], since: "0.16.0" },
9
9
  { verb: "hosts remove|rename", does: "remove or rename a self-hosted machine in this account", needs: "admin", routes: [], since: "0.16.0" },
10
10
  { verb: "runner update|auto-update", does: "move this machine's self-hosted runner to the release Catalyst approved, and check for one every 15 minutes", needs: "member", routes: [], since: "0.16.16" },
11
+ { verb: "update", does: "list every installed component against the release Catalyst approved, move the runner images to it, and name the command for the rest", needs: "member", routes: [], since: "0.16.31" },
12
+ { verb: "update sdk", does: "move one project's Catalyst SDK to the approved versions with its own package manager, when the person asks", needs: "member", routes: [], since: "0.16.31" },
13
+ { verb: "update policy", does: "turn automatic updates off or on for one installed component, or set a project's SDK to notify only", needs: "member", routes: [], since: "0.16.31" },
11
14
  { verb: "runner status|stop|start", does: "stop this machine's self-hosted runner taking work, or start it again with the same registration", needs: "member", routes: [], since: "0.16.27" },
12
15
  { verb: "runner uninstall", does: "remove this machine's self-hosted runner and revoke its registration and runner key", needs: "admin", routes: [], since: "0.16.27" },
13
16
  // CTC-4680: plain `catalyst setup` runs the onboard flow, so install.sh may hand off to it. install.sh
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { configDirFor, publishedCachePathFor } from "./config.js";
5
- import { latestPublishedVersion } from "./published.js";
5
+ import { newestCliVersion } from "./published.js";
6
6
  import { semverOlder } from "./semver.js";
7
7
  const DAY_MS = 24 * 60 * 60 * 1000;
8
8
  const RELEASE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
@@ -62,12 +62,15 @@ export async function maybePrintPublishedUpdateNotice(ctx, options) {
62
62
  rmSync(temporary, { force: true });
63
63
  }
64
64
  // Record the attempt before the bounded lookup, including failures, so offline commands do
65
- // not repeatedly contact npm. Reuse ready's anonymous request and version cache.
66
- const reply = await latestPublishedVersion(ctx);
65
+ // not repeatedly contact npm. Reuse ready's anonymous request and version cache. Once this
66
+ // install has read an approved release, that is the version named (CTC-6708).
67
+ const reply = await newestCliVersion(ctx);
67
68
  if (reply.latest &&
68
69
  RELEASE.test(reply.latest) &&
69
70
  semverOlder(options.version, reply.latest)) {
70
- ctx.stderr(`Catalyst ${reply.latest} is out. Update with: npm install -g @catalyst-cloud/cli@latest`);
71
+ ctx.stderr(reply.source === "approved"
72
+ ? `Catalyst ${reply.latest} is approved for this workspace. Update with: catalyst update`
73
+ : `Catalyst ${reply.latest} is out. Update with: npm install -g @catalyst-cloud/cli@latest`);
71
74
  }
72
75
  }
73
76
  catch {
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cmdHosts } from "./hosts.js";
2
2
  import { cmdRunner } from "./runner-command.js";
3
+ import { cmdUpdate } from "./update-command.js";
3
4
  import { cmdSeat } from "./seat.js";
4
5
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
5
6
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
@@ -21,6 +22,7 @@ import { openBrowserChecked as defaultOpenBrowser } from "./browser.js";
21
22
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
22
23
  import { cmdQuery } from "./query.js";
23
24
  import { maybePrintPublishedUpdateNotice } from "./cli-update-notice.js";
25
+ import { semverOlder } from "./semver.js";
24
26
  import { cmdReady } from "./ready.js";
25
27
  import { cmdReplica } from "./replica.js";
26
28
  import { cmdRuntime } from "./runtime-verb.js";
@@ -90,6 +92,7 @@ export function usageText() {
90
92
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
91
93
  " catalyst runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] (this machine's self-hosted runner)",
92
94
  " catalyst runner update [--check] | runner auto-update [status|on|off] (move this machine's runner to the approved release)",
95
+ " catalyst update [--check] [--project <path>] | update policy [<component> on|off] (every installed component against the approved release)",
93
96
  " catalyst mcp add|list|remove (vault references only) | mcp status <name> (a server's state, grants and last 50 tool calls)",
94
97
  " catalyst contract [--refresh] [--path <a.b.c>]",
95
98
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
@@ -198,6 +201,10 @@ function maybePrintUpdateNotice(args, ctx) {
198
201
  const manifest = readManifest();
199
202
  if (cfg.lastSkillBundleVersion === manifest.version)
200
203
  return;
204
+ // CTC-6741: a newer CLI selected by `catalyst update` records its own bundle here, and the older
205
+ // npm-installed one still runs for `catalyst runtime`. Copied skills only ever move forward.
206
+ if (cfg.lastSkillBundleVersion && semverOlder(manifest.version, cfg.lastSkillBundleVersion))
207
+ return;
201
208
  const previous = cfg.lastSkillBundleVersion;
202
209
  if (previous) {
203
210
  const entry = parseChangelogEntry(readChangelog(), manifest.version);
@@ -565,6 +572,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
565
572
  return await cmdHosts(args, ctx, deps.hosts);
566
573
  case "runner":
567
574
  return await cmdRunner(args, ctx, deps.runner);
575
+ case "update":
576
+ return await cmdUpdate(args, ctx, deps.update);
568
577
  case "connections":
569
578
  return await cmdConnections(args, ctx, deps.connections ?? {});
570
579
  default:
@@ -635,6 +644,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
635
644
  "work",
636
645
  "legacy",
637
646
  "onboard",
647
+ "update",
638
648
  ].map((v) => [v, true]));
639
649
  /** CTC-4680: the Pixel Nucleus mark belongs to `catalyst setup` alone. `setup` runs the onboard
640
650
  * flow, so this is read from the verb typed, before setup becomes onboard. */
package/dist/published.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
+ import { readApprovalCache, supportedReleaseLine } from "./approved-release.js";
7
8
  import { LEGACY_PACKAGE_NAME, publishedCachePathFor } from "./config.js";
8
9
  /** CTC-3479 — asks about the forwarder package whichever package is installed. The release publishes
9
10
  * @catalyst-cloud/cli first and the forwarder last, at one version, so the forwarder's `latest`
@@ -77,3 +78,17 @@ export async function latestPublishedVersion(ctx, opts = {}) {
77
78
  writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
78
79
  return { latest, reason: null, source: "network" };
79
80
  }
81
+ /**
82
+ * CTC-6708 — the CLI's "newest" answer is the approved release once this install has read one: every
83
+ * applying release check records it, the 15-minute schedule included. Reading it is local, so this
84
+ * adds no network call. An install that has never read an approved release, or read one on another
85
+ * train, falls back to the npm lookup above, and its answer stays labelled the latest published version.
86
+ */
87
+ export async function newestCliVersion(ctx, opts = {}) {
88
+ const approved = readApprovalCache(ctx.home)?.release;
89
+ const cli = approved?.members.cli;
90
+ // CTC-6741: a release on another train is never applied, so it is never the answer either.
91
+ if (approved && cli && approved.releaseLine === supportedReleaseLine())
92
+ return { latest: cli, reason: null, source: "approved", ...(approved.skills.cloud ? { skills: approved.skills.cloud.version } : {}) };
93
+ return latestPublishedVersion(ctx, opts);
94
+ }
package/dist/ready.js CHANGED
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, join } from "node:path";
6
6
  import { DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, upgradeCommand, } from "./config.js";
7
7
  import { contractVersionInRange, readContractCache } from "./contract.js";
8
8
  import { CliError } from "./errors.js";
9
- import { latestPublishedVersion } from "./published.js";
9
+ import { newestCliVersion } from "./published.js";
10
10
  import { replicaStatus, writerIsRunning, } from "./replica.js";
11
11
  import { loadHttpSdk } from "./sdk.js";
12
12
  import { FIX_COMMAND, detectRuntime, runtimeVerdict, supportedRangeText, } from "./runtime.js";
@@ -234,7 +234,7 @@ export async function readyReport(ctx, deps) {
234
234
  const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
235
235
  const offline = deps.offline === true || ctx.env.CATALYST_SKILLS_OFFLINE === "1";
236
236
  if (!offline) {
237
- const pub = await (deps.fetchLatestRelease ?? (() => latestPublishedVersion(ctx)))();
237
+ const pub = await (deps.fetchLatestRelease ?? (() => newestCliVersion(ctx)))();
238
238
  if (pub.latest === null) {
239
239
  checks.push({
240
240
  id: "cliRelease",
@@ -252,7 +252,10 @@ export async function readyReport(ctx, deps) {
252
252
  id: "cliRelease",
253
253
  ok: false,
254
254
  note: true,
255
- line: `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
255
+ // CTC-6708 — once this install has read an approved release, that is the answer.
256
+ line: pub.source === "approved"
257
+ ? `cliRelease: ${installedCli} installed is older than the approved ${pub.latest} — update: catalyst update`
258
+ : `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
256
259
  });
257
260
  }
258
261
  // clause 1 — the skill files on disk, read independently of whichever CLI is answering. Both
@@ -262,7 +265,8 @@ export async function readyReport(ctx, deps) {
262
265
  // older than any stamp, which is why the stamped half says "oldest STAMPED" and the unstamped
263
266
  // names are always printed rather than hidden behind whichever branch happened to win.
264
267
  const found = installedBundleVersion(skillsDir, deps.skillNames);
265
- const stampBehind = found.version !== null && semverOlder(found.version, pub.latest);
268
+ const skillsTarget = pub.source === "approved" ? (pub.skills ?? pub.latest) : pub.latest;
269
+ const stampBehind = found.version !== null && semverOlder(found.version, skillsTarget);
266
270
  const unstampedBehind = found.unstamped.length > 0 &&
267
271
  !semverOlder(pub.latest, FIRST_STAMPED_VERSION);
268
272
  if (stampBehind || unstampedBehind) {
@@ -277,7 +281,9 @@ export async function readyReport(ctx, deps) {
277
281
  id: "skillsRelease",
278
282
  ok: false,
279
283
  note: true,
280
- line: `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
284
+ line: pub.source === "approved"
285
+ ? `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the approved bundle is ${skillsTarget} — update: catalyst update`
286
+ : `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
281
287
  });
282
288
  }
283
289
  }
package/dist/replica.js CHANGED
@@ -3,10 +3,11 @@
3
3
  // `status` is the check every skill runs first and it needs no network and no SDK: the pidfile, the
4
4
  // writer-lock heartbeat, and the `sync_meta.cursor` row (read through node:sqlite read-only). Exit
5
5
  // 0 fresh, 1 present but stale, 2 not configured, 3 absent. `--probe` adds the one network call.
6
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
+ import { join } from "node:path";
8
9
  import { flagBool, flagInt, flagString, positionals } from "./args.js";
9
- import { apiBase, loadConfig, replicaDbPath, replicaOptedIn } from "./config.js";
10
+ import { apiBase, configDirFor, loadConfig, readManifest, replicaDbPath, replicaOptedIn } from "./config.js";
10
11
  import { detachSelf } from "./detach.js";
11
12
  import { CliError, UsageError } from "./errors.js";
12
13
  import { apiClient } from "./transport.js";
@@ -62,7 +63,8 @@ export function readWriterState(dbPath) {
62
63
  typeof rec.consecutiveFailures !== "number" ||
63
64
  !(rec.lastError === null || typeof rec.lastError === "string") ||
64
65
  !(rec.lastFailureAt === null || typeof rec.lastFailureAt === "number") ||
65
- !stoppedOk) {
66
+ !stoppedOk ||
67
+ !(rec.version === undefined || typeof rec.version === "string")) {
66
68
  return null;
67
69
  }
68
70
  return rec;
@@ -72,7 +74,7 @@ export function readWriterState(dbPath) {
72
74
  }
73
75
  }
74
76
  export function writeWriterState(dbPath, s, nowMs) {
75
- writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, updatedAt: nowMs }));
77
+ writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, version: readManifest().version, updatedAt: nowMs }));
76
78
  }
77
79
  /** Remove the writer state sidecar; a no-op (never throws) when it is already gone. */
78
80
  export function clearWriterState(dbPath) {
@@ -83,6 +85,25 @@ export function clearWriterState(dbPath) {
83
85
  // already gone
84
86
  }
85
87
  }
88
+ /** A restart a release check began and has not finished (update-replica.ts, CTC-6708). The owner's
89
+ * own `replica stop` clears it, so their stop is never undone. */
90
+ export function pendingRestartPathFor(home) {
91
+ return join(configDirFor(home), "replica-restart.json");
92
+ }
93
+ /** The restart a release check began for `dbPath` and has not confirmed, with the CLI version it
94
+ * expects the new writer to run when the record names one. */
95
+ export function pendingRestart(home, dbPath) {
96
+ try {
97
+ const raw = JSON.parse(readFileSync(pendingRestartPathFor(home), "utf8"));
98
+ if (typeof raw !== "object" || raw === null || raw.dbPath !== dbPath)
99
+ return null;
100
+ const target = raw.target;
101
+ return { dbPath, target: typeof target === "string" ? target : null };
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
86
107
  export function pidAlive(pid) {
87
108
  if (pid === null || !Number.isInteger(pid) || pid <= 0)
88
109
  return false;
@@ -348,7 +369,15 @@ async function cmdStatus(args, ctx) {
348
369
  status.lag = status.cursor === null ? head.body.cursor : head.body.cursor - status.cursor;
349
370
  }
350
371
  }
351
- ctx.stdout(args.json ? JSON.stringify(status) : statusLine(status));
372
+ const restart = status.dbPath !== null && !writerIsRunning(status) ? pendingRestart(ctx.home, status.dbPath) : null;
373
+ if (args.json)
374
+ ctx.stdout(JSON.stringify(restart ? { ...status, pendingRestart: restart } : status));
375
+ else {
376
+ ctx.stdout(statusLine(status));
377
+ // CTC-6743: a writer a release check stopped and restarted is down for that reason, not its owner's.
378
+ if (restart)
379
+ ctx.stdout(` a release check restarted it${restart.target ? ` on CLI ${restart.target}` : ""} and the new writer is not running; its synced data is kept at ${status.dbPath}, and the next release check starts it again`);
380
+ }
352
381
  return status.exitCode;
353
382
  }
354
383
  async function cmdStart(args, ctx, cfg, dbPath, deps) {
@@ -382,7 +411,9 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
382
411
  if (recordOwned)
383
412
  return;
384
413
  recordOwned = true;
385
- clearWriterState(dbPath);
414
+ // CTC-6743: a fresh, healthy record rather than none, so a release check can read the CLI
415
+ // version this writer runs without waiting for a failed pull to write one.
416
+ writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: 0, lastError: null, lastFailureAt: null, stopped: null }, ctx.now().getTime());
386
417
  };
387
418
  const resetFailures = () => {
388
419
  if (failures === 0)
@@ -413,6 +444,11 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
413
444
  log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
414
445
  onStatus: (s) => {
415
446
  ctx.stderr(`[replica] status=${s}`);
447
+ // CTC-6743: a status means start() already holds the writer lock, so this run owns the
448
+ // record. Taking it here, not after a long first seed, means a release check never reads a
449
+ // seeding writer as one with no version and restarts it every interval.
450
+ if (s === "resyncing" || s === "connecting")
451
+ takeOwnershipOfRecord();
416
452
  // A "live" that does NOT follow a re-seed is just the socket coming up — the warm failing
417
453
  // loop passes through it every cycle, so it must not reset the count (CTC-2499).
418
454
  if (s === "resyncing") {
@@ -544,6 +580,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
544
580
  return 0;
545
581
  }
546
582
  function cmdStop(ctx, dbPath) {
583
+ rmSync(pendingRestartPathFor(ctx.home), { force: true });
547
584
  const pid = readPidfile(dbPath);
548
585
  if (pid === null) {
549
586
  ctx.stdout(`no pidfile at ${pidfilePath(dbPath)} — nothing to stop (a foreground writer is stopped with Ctrl-C)`);