@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/dist/update.js ADDED
@@ -0,0 +1,586 @@
1
+ // update.ts — one release check for every component an install keeps (CTC-6708). It reads what is
2
+ // installed, reads the approved release once, and decides for each component on its own: current,
3
+ // behind, held by the owner's setting, newer than approved, absent, or unknown with a reason. A held
4
+ // or failed component never blocks the others, and nothing in a check changes the machine.
5
+ //
6
+ // The approved record comes from one read: the enrolled machine's desired-release answer when a
7
+ // runner is installed (its images and its `components` share one snapshot), else the signed-in
8
+ // member route. The runner, supervisor and watchdog move through runner-update.ts's engine with that
9
+ // same read, so the image half and the component half can never come from different approvals.
10
+ //
11
+ // This CLI applies the image components itself, moves the CLI to the approved version (installed
12
+ // beside the running one and selected through the person's own command: update-cli.ts), and then
13
+ // restarts a running local replica on it with its data (update-replica.ts). The Cloud skills pack
14
+ // moves to its own approved version from that version's package, even while the CLI is held
15
+ // (update-skills.ts), and its Claude plugin form moves through Claude Code when the plugin's
16
+ // marketplace serves the approved version (update-plugin.ts). The Dev pack carries no version this CLI can read, so it is reported, never
17
+ // moved. A project's SDK is reported
18
+ // with its update command; an applying run changes a project's files only when its owner turned that
19
+ // project's SDK updates on, and a check never does.
20
+ import { compareVersions, hostApproval, readApprovalCache, readMemberApproval, supportedReleaseLine, writeApprovalCache, } from "./approved-release.js";
21
+ import { loadConfig } from "./config.js";
22
+ import { applyCliUpdate } from "./update-cli.js";
23
+ import { readInventory } from "./update-inventory.js";
24
+ import { restartPending, restartReplica } from "./update-replica.js";
25
+ import { applyCloudPackUpdate } from "./update-skills.js";
26
+ import { applyCloudPluginUpdate, CLOUD_PLUGIN_COMMAND } from "./update-plugin.js";
27
+ import { onboardStatePath, readOnboardJournal } from "./onboard.js";
28
+ import { effectivePolicy, projectIdentityHolds, readUpdatePolicy, UPDATE_COMPONENTS, withMigratedHolds, } from "./update-policy.js";
29
+ import { applySdkUpdate, inspectSdkProject } from "./update-sdk.js";
30
+ import { decideRelease, isRecord, parseDesiredRelease, RELEASE_COMPONENTS, runRunnerUpdate, } from "./runner-update.js";
31
+ export const COMPONENT_LABELS = {
32
+ runner: "Runner",
33
+ supervisor: "Supervisor",
34
+ watchdog: "Watchdog",
35
+ cli: "CLI",
36
+ "skills-cloud": "Cloud skills",
37
+ "skills-cloud-plugin": "Cloud skills plugin",
38
+ "skills-dev": "Dev skills",
39
+ replica: "Local replica",
40
+ };
41
+ const SDK_LABELS = {
42
+ "@catalyst-cloud/sdk": "SDK",
43
+ "@catalyst-cloud/sdk-replica-node": "SDK replica (Node)",
44
+ "@catalyst-cloud/sdk-replica-browser": "SDK replica (browser)",
45
+ };
46
+ /** Setup's record says the owner turned the installer's daily update off. Unreadable is no evidence. */
47
+ function dailyUpdateOff(ctx) {
48
+ try {
49
+ return readOnboardJournal(onboardStatePath(ctx.home, ctx.env))?.dailyUpdate?.state === "off";
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** The owner's saved settings, with the holds their setup choices carry (update-policy.ts). */
56
+ export function readEffectivePolicy(ctx) {
57
+ return withMigratedHolds(readUpdatePolicy(ctx.home), dailyUpdateOff(ctx));
58
+ }
59
+ /** An engine whose desired-release read happens once per pass, however many readers ask. */
60
+ export function sharedReleaseRead(engine) {
61
+ const reads = new Map();
62
+ const read = (dir, signal) => {
63
+ let pending = reads.get(dir);
64
+ if (!pending) {
65
+ pending = engine.desiredRelease(dir, signal);
66
+ reads.set(dir, pending);
67
+ }
68
+ return pending;
69
+ };
70
+ return { ...engine, desiredRelease: read, read: (dir) => read(dir) };
71
+ }
72
+ function imageApproval(read) {
73
+ const decision = decideRelease(null, read);
74
+ if (decision.action === "retain")
75
+ return { release: null, reason: decision.reason };
76
+ const body = read.kind === "answered" && isRecord(read.body) ? read.body.release : null;
77
+ return { release: parseDesiredRelease(body), reason: "approved" };
78
+ }
79
+ const shortDigest = (reference) => reference.slice(reference.indexOf("@") + 1, reference.indexOf("@") + 20);
80
+ function versionStatus(installed, approved, enabled) {
81
+ const order = compareVersions(installed, approved);
82
+ if (order === 0)
83
+ return { status: "current", reason: "current" };
84
+ if (order > 0)
85
+ return { status: "installed_newer", reason: "installed_newer" };
86
+ return enabled ? { status: "update_available", reason: "behind_approved" } : { status: "held", reason: "owner_setting" };
87
+ }
88
+ function imageRows(inventory, policy, approval) {
89
+ return RELEASE_COMPONENTS.map((id) => {
90
+ const p = effectivePolicy(policy, id);
91
+ const row = { id, policy: p.enabled ? "on" : "off", policySource: p.source, action: null };
92
+ const images = inventory.images;
93
+ if (!images.installed)
94
+ return { ...row, installed: null, approved: null, status: "not_installed", reason: "not_installed" };
95
+ const installedRef = images.references[id];
96
+ const release = approval?.release ?? null;
97
+ const approvedRef = release?.images[id].reference ?? null;
98
+ const installedLabel = installedRef === null
99
+ ? null
100
+ : release && installedRef === approvedRef
101
+ ? `generation ${release.generation}`
102
+ : images.generation !== null
103
+ ? `generation ${images.generation}`
104
+ : shortDigest(installedRef);
105
+ const base = {
106
+ ...row,
107
+ installed: installedLabel,
108
+ approved: release ? `generation ${release.generation}` : null,
109
+ installedReference: installedRef,
110
+ approvedReference: approvedRef,
111
+ };
112
+ if (installedRef === null)
113
+ return { ...base, status: "unknown", reason: "image_unreadable" };
114
+ if (!release)
115
+ return { ...base, status: "approval_unavailable", reason: approval?.reason ?? "approval_unavailable" };
116
+ if (installedRef === approvedRef)
117
+ return { ...base, status: "current", reason: "current" };
118
+ if (images.generation !== null && images.generation > release.generation)
119
+ return { ...base, status: "installed_newer", reason: "not_newer" };
120
+ return p.enabled ? { ...base, status: "update_available", reason: "behind_approved" } : { ...base, status: "held", reason: "owner_setting" };
121
+ });
122
+ }
123
+ function componentRows(inventory, policy, release, unavailable, crossTrain) {
124
+ const row = (id) => {
125
+ const p = effectivePolicy(policy, id);
126
+ return { id, policy: p.enabled ? "on" : "off", policySource: p.source, enabled: p.enabled };
127
+ };
128
+ const finish = (r, rest) => ({
129
+ id: r.id,
130
+ policy: r.policy,
131
+ policySource: r.policySource,
132
+ ...rest,
133
+ });
134
+ const cliTarget = release?.members.cli ?? null;
135
+ const cli = row("cli");
136
+ const cliRow = (() => {
137
+ const installed = inventory.cli.version;
138
+ if (cliTarget === null)
139
+ return finish(cli, { installed, approved: null, status: "approval_unavailable", reason: unavailable, action: null });
140
+ if (crossTrain)
141
+ return finish(cli, { installed, approved: cliTarget, status: "cross_train", reason: "cross_train", action: null });
142
+ // An applying run moves the CLI itself: no command to hand the owner.
143
+ return finish(cli, { installed, approved: cliTarget, ...versionStatus(installed, cliTarget, cli.enabled), action: null });
144
+ })();
145
+ const packRow = (id, pack) => {
146
+ const r = row(id);
147
+ const target = release?.skills[pack] ?? null;
148
+ const found = inventory.skills[pack];
149
+ const approved = target?.version ?? null;
150
+ if (found.state === "not_installed")
151
+ return finish(r, { installed: null, approved, status: "not_installed", reason: "not_installed", action: null });
152
+ if (found.state === "unknown")
153
+ return finish(r, { installed: null, approved, status: "unknown", reason: found.reason, action: null });
154
+ if (target === null)
155
+ return finish(r, { installed: found.version, approved: null, status: "approval_unavailable", reason: unavailable, action: null });
156
+ if (crossTrain)
157
+ return finish(r, { installed: found.version, approved, status: "cross_train", reason: "cross_train", action: null });
158
+ const decided = versionStatus(found.version, target.version, r.enabled);
159
+ return finish(r, {
160
+ installed: found.version,
161
+ approved,
162
+ ...decided,
163
+ // An applying run moves the Cloud pack itself; the Dev pack's command is the owner's to run.
164
+ action: decided.status === "update_available" && pack === "dev" ? `npx skills add https://github.com/${target.repository}/tree/${target.commit} --all -g` : null,
165
+ });
166
+ };
167
+ // CTC-6791: the Claude plugin form follows the Cloud pack's setting, and is listed only where it is installed.
168
+ const pluginRow = (() => {
169
+ const found = inventory.plugin;
170
+ if (found.state === "not_installed")
171
+ return null;
172
+ const r = { ...row("skills-cloud"), id: "skills-cloud-plugin" };
173
+ const approved = release?.members.plugin ?? null;
174
+ if (found.state === "unknown")
175
+ return finish(r, { installed: null, approved, status: "unknown", reason: found.reason, action: null });
176
+ if (approved === null)
177
+ return finish(r, { installed: found.version, approved: null, status: "approval_unavailable", reason: unavailable, action: null });
178
+ if (crossTrain)
179
+ return finish(r, { installed: found.version, approved, status: "cross_train", reason: "cross_train", action: null });
180
+ const decided = versionStatus(found.version, approved, r.enabled);
181
+ // An applying run moves it through Claude Code; with no `claude` command found, the owner runs it.
182
+ return finish(r, { installed: found.version, approved, ...decided, action: decided.status === "update_available" && found.claude === null ? CLOUD_PLUGIN_COMMAND : null });
183
+ })();
184
+ const replica = row("replica");
185
+ const replicaRow = (() => {
186
+ const found = inventory.replica;
187
+ if (found.state === "not_installed")
188
+ return finish(replica, { installed: null, approved: cliTarget, status: "not_installed", reason: "not_installed", action: null });
189
+ if (found.state === "unknown")
190
+ return finish(replica, { installed: null, approved: cliTarget, status: "unknown", reason: found.reason, action: null });
191
+ if (found.state === "stopped")
192
+ return finish(replica, { installed: null, approved: cliTarget, status: "stopped", reason: "stopped", action: null });
193
+ if (cliTarget === null)
194
+ return finish(replica, { installed: found.version, approved: null, status: "approval_unavailable", reason: unavailable, action: null });
195
+ if (crossTrain)
196
+ return finish(replica, { installed: found.version, approved: cliTarget, status: "cross_train", reason: "cross_train", action: null });
197
+ // CTC-6743: a running writer that recorded no version started on a CLI from before writers
198
+ // recorded one, so it is older than any approved CLI and restarts like any other old writer.
199
+ const order = found.version === null ? -1 : compareVersions(found.version, cliTarget);
200
+ const base = { installed: found.version, approved: cliTarget, action: null };
201
+ if (order === 0)
202
+ return finish(replica, { ...base, status: "current", reason: "current" });
203
+ if (order > 0)
204
+ return finish(replica, { ...base, status: "installed_newer", reason: "installed_newer" });
205
+ if (!replica.enabled)
206
+ return finish(replica, { ...base, status: "held", reason: "owner_setting" });
207
+ // The replica runs on the installed CLI: it moves when the CLI does.
208
+ if (compareVersions(inventory.cli.version, cliTarget) < 0)
209
+ return cli.enabled
210
+ ? finish(replica, { ...base, status: "restart_after_cli", reason: "cli_update_pending" })
211
+ : finish(replica, { ...base, status: "waiting_for_cli", reason: "dependency_held" });
212
+ return finish(replica, { ...base, status: "update_available", reason: "restart_needed" });
213
+ })();
214
+ return [cliRow, packRow("skills-cloud", "cloud"), ...(pluginRow ? [pluginRow] : []), packRow("skills-dev", "dev"), replicaRow];
215
+ }
216
+ function projectReports(policy, release, extra) {
217
+ const reports = [];
218
+ for (const project of policy.projects) {
219
+ if (!projectIdentityHolds(project)) {
220
+ reports.push({
221
+ path: project.path,
222
+ packageManager: null,
223
+ packages: [],
224
+ status: "unknown",
225
+ reason: "project_identity_changed",
226
+ command: null,
227
+ packageManagerCommand: null,
228
+ registered: true,
229
+ policy: project.sdk,
230
+ applied: null,
231
+ });
232
+ continue;
233
+ }
234
+ reports.push({ ...inspectSdkProject(project.path, release), registered: true, policy: project.sdk, applied: null });
235
+ }
236
+ if (extra !== undefined && !reports.some((r) => r.path === extra))
237
+ reports.push({ ...inspectSdkProject(extra, release), registered: false, policy: "notify", applied: null });
238
+ return reports;
239
+ }
240
+ /** Map the image engine's result onto the three image rows it moved. */
241
+ function applyImageResult(rows, result) {
242
+ for (const row of rows) {
243
+ if (row.status === "not_installed")
244
+ continue;
245
+ if (result.outcome === "updated" || result.outcome === "recorded") {
246
+ row.status = "updated";
247
+ row.reason = result.outcome;
248
+ row.installed = row.approved;
249
+ row.installedReference = row.approvedReference ?? row.installedReference ?? null;
250
+ }
251
+ else if (result.outcome === "deferred_busy" || result.outcome === "retained") {
252
+ if (row.status === "update_available") {
253
+ row.status = "pending";
254
+ row.reason = result.detail;
255
+ }
256
+ }
257
+ else if (result.outcome === "failed" || result.outcome === "rolled_back") {
258
+ if (row.status === "update_available") {
259
+ row.status = "failed";
260
+ row.reason = result.detail;
261
+ }
262
+ }
263
+ }
264
+ }
265
+ function aggregateExit(report) {
266
+ if (report.components.some((c) => c.status === "failed"))
267
+ return 1;
268
+ if (report.projects.some((p) => p.applied?.outcome === "failed"))
269
+ return 1;
270
+ if (report.images && report.images.exitCode !== 0)
271
+ return 1;
272
+ // No usable approval for anything: the check could not answer.
273
+ const imagesAnswered = report.components.some((c) => RELEASE_COMPONENTS.some((id) => id === c.id) && c.approved !== null);
274
+ if (report.unavailable && !imagesAnswered)
275
+ return 1;
276
+ // Applying: an enabled component that is behind and that this CLI does not move needs the owner.
277
+ if (report.mode === "apply" && report.components.some((c) => c.status === "update_available" && c.action !== null))
278
+ return 1;
279
+ return 0;
280
+ }
281
+ /** One release check. Check mode reads only: no lock, no cache, no settings and no install change. */
282
+ export async function runUpdate(ctx, options) {
283
+ const policy = readEffectivePolicy(ctx);
284
+ const inventory = readInventory(ctx, options.imagesDir);
285
+ let engine = null;
286
+ let approval;
287
+ let images = null;
288
+ if (inventory.images.installed) {
289
+ engine = sharedReleaseRead(options.engine());
290
+ const read = await engine.read(options.imagesDir);
291
+ images = imageApproval(read);
292
+ approval = hostApproval(read);
293
+ // The runner's own read failed (Docker stopped, say): a signed-in CLI can still ask the member
294
+ // route. Its answer names the components; the images keep the host's verdict.
295
+ if (approval.kind === "unavailable" && (approval.reason === "host_unreadable" || approval.reason === "unreachable")) {
296
+ const member = await readMemberApproval(ctx);
297
+ if (member.kind === "approved" || member.reason !== "signed_out")
298
+ approval = member;
299
+ }
300
+ }
301
+ else {
302
+ approval = await readMemberApproval(ctx);
303
+ }
304
+ const release = approval.kind === "approved" ? approval.release : null;
305
+ const supported = supportedReleaseLine();
306
+ const crossTrain = release !== null && release.releaseLine !== supported ? { supported, approved: release.releaseLine } : null;
307
+ const unavailableReason = approval.kind === "unavailable" ? approval.reason : "approval_unavailable";
308
+ const cached = approval.kind === "unavailable" ? readApprovalCache(ctx.home) : null;
309
+ const imageList = imageRows(inventory, policy, crossTrain ? { release: null, reason: "cross_train" } : images);
310
+ const components = [...imageList, ...componentRows(inventory, policy, release, unavailableReason, crossTrain !== null)];
311
+ // A writer an earlier restart stopped is not one its owner stopped: it is started again.
312
+ const stoppedRow = components.find((c) => c.id === "replica" && c.status === "stopped");
313
+ if (stoppedRow && inventory.replica.state === "stopped" && restartPending(ctx.home, inventory.replica.dbPath))
314
+ Object.assign(stoppedRow, { status: "pending", reason: "restart_interrupted" });
315
+ const projects = projectReports(policy, crossTrain ? null : release, options.project);
316
+ let imageResult = options.imageResult ?? null;
317
+ if (options.mode === "apply" && engine !== null && imageResult === null && crossTrain === null) {
318
+ // Re-read the owner's settings just before anything moves: a hold set during the read wins.
319
+ const current = readEffectivePolicy(ctx);
320
+ const held = RELEASE_COMPONENTS.filter((id) => !effectivePolicy(current, id).enabled);
321
+ if (held.length === 0) {
322
+ imageResult = await runRunnerUpdate(engine, { dir: options.imagesDir });
323
+ applyImageResult(imageList, imageResult);
324
+ }
325
+ else {
326
+ // The image engine moves all three together; one held image holds its peers until it is lifted.
327
+ for (const row of imageList)
328
+ if (row.status === "update_available") {
329
+ row.status = "held";
330
+ row.reason = "peer_image_held";
331
+ }
332
+ }
333
+ }
334
+ if (options.mode === "apply" && release !== null && crossTrain === null) {
335
+ // Re-read the owner's settings just before the CLI and replica move: a hold set during the read wins.
336
+ const current = readEffectivePolicy(ctx);
337
+ const cliRow = components.find((c) => c.id === "cli");
338
+ const replicaRow = components.find((c) => c.id === "replica");
339
+ if (cliRow && cliRow.status === "update_available" && cliRow.approved !== null) {
340
+ if (!effectivePolicy(current, "cli").enabled)
341
+ Object.assign(cliRow, { status: "held", reason: "owner_setting" });
342
+ else {
343
+ const moved = (options.cliApply ?? applyCliUpdate)(ctx.home, inventory.cli.version, cliRow.approved);
344
+ if (moved.outcome === "failed")
345
+ Object.assign(cliRow, { status: "failed", reason: moved.detail });
346
+ else if (moved.outcome === "current")
347
+ // Already selected (or newer): the person's own command runs `moved.version`.
348
+ Object.assign(cliRow, { status: moved.detail === "installed_newer" ? "installed_newer" : "current", reason: moved.detail, installed: moved.version });
349
+ else
350
+ Object.assign(cliRow, { status: "updated", reason: moved.detail, installed: moved.version });
351
+ }
352
+ }
353
+ const packRow = components.find((c) => c.id === "skills-cloud");
354
+ const pack = inventory.skills.cloud;
355
+ if (packRow && packRow.status === "update_available" && packRow.approved !== null && pack.state === "installed") {
356
+ if (!effectivePolicy(current, "skills-cloud").enabled)
357
+ Object.assign(packRow, { status: "held", reason: "owner_setting" });
358
+ else {
359
+ const moved = (options.packApply ?? applyCloudPackUpdate)(ctx.home, pack.dir, packRow.approved);
360
+ if (moved.outcome === "updated")
361
+ Object.assign(packRow, { status: "updated", reason: moved.detail, installed: moved.version });
362
+ else if (moved.outcome === "linked")
363
+ Object.assign(packRow, { status: "unknown", reason: moved.detail });
364
+ else
365
+ Object.assign(packRow, { status: "failed", reason: moved.detail, installed: moved.version ?? packRow.installed });
366
+ }
367
+ }
368
+ const pluginRow = components.find((c) => c.id === "skills-cloud-plugin");
369
+ const plugin = inventory.plugin;
370
+ if (pluginRow && pluginRow.status === "update_available" && pluginRow.approved !== null && plugin.state === "installed" && plugin.claude !== null) {
371
+ if (!effectivePolicy(current, "skills-cloud").enabled)
372
+ Object.assign(pluginRow, { status: "held", reason: "owner_setting" });
373
+ else {
374
+ const moved = (options.pluginApply ?? applyCloudPluginUpdate)(ctx.home, ctx.env, pluginRow.approved, plugin.scope, { claudePath: plugin.claude });
375
+ if (moved.outcome === "updated")
376
+ Object.assign(pluginRow, { status: "updated", reason: moved.detail, installed: moved.version });
377
+ else if (moved.outcome === "waiting")
378
+ Object.assign(pluginRow, { status: "pending", reason: moved.detail });
379
+ else if (moved.outcome === "no_claude")
380
+ Object.assign(pluginRow, { action: CLOUD_PLUGIN_COMMAND });
381
+ else
382
+ Object.assign(pluginRow, { status: "failed", reason: moved.detail, installed: moved.version ?? pluginRow.installed });
383
+ }
384
+ }
385
+ if (replicaRow && replicaRow.approved !== null) {
386
+ const cliRuns = cliRow?.status === "updated" || cliRow?.status === "current" || cliRow?.status === "installed_newer";
387
+ const due = (replicaRow.status === "restart_after_cli" && cliRuns) ||
388
+ (replicaRow.status === "update_available" && replicaRow.reason === "restart_needed") ||
389
+ (replicaRow.status === "pending" && replicaRow.reason === "restart_interrupted");
390
+ if (replicaRow.status === "restart_after_cli" && cliRow?.status === "failed")
391
+ Object.assign(replicaRow, { status: "pending", reason: "cli_update_failed" });
392
+ else if (due && !effectivePolicy(current, "replica").enabled)
393
+ Object.assign(replicaRow, { status: "held", reason: "owner_setting" });
394
+ else if (due) {
395
+ const restart = options.replicaRestart ??
396
+ (async (target) => {
397
+ const cfg = loadConfig(ctx.home);
398
+ // The person's own `catalyst`: it loads the selected version itself (bin/launch.js).
399
+ const launcher = process.argv[1];
400
+ if (!cfg || launcher === undefined)
401
+ return { outcome: "failed", detail: "writer_unidentified", version: null };
402
+ return restartReplica(ctx, cfg, target, { launcher });
403
+ });
404
+ // The version the person's own command runs now, which the new writer must report.
405
+ const target = cliRuns ? (cliRow?.installed ?? replicaRow.approved) : inventory.cli.version;
406
+ const restarted = await restart(target);
407
+ if (restarted.outcome === "updated")
408
+ Object.assign(replicaRow, { status: "updated", reason: restarted.detail, installed: restarted.version });
409
+ else if (restarted.outcome === "stopped")
410
+ Object.assign(replicaRow, { status: "stopped", reason: "stopped", installed: null });
411
+ else
412
+ Object.assign(replicaRow, { status: "failed", reason: restarted.detail, installed: restarted.version ?? replicaRow.installed });
413
+ }
414
+ }
415
+ // Only a project whose owner turned its SDK updates on, still the directory they registered.
416
+ for (const project of projects)
417
+ if (project.registered && project.policy === "auto" && project.status === "update_available") {
418
+ const result = applySdkUpdate(ctx.home, project.path, release, options.sdkExec);
419
+ Object.assign(project, result.project, { applied: { outcome: result.outcome, detail: result.detail, moved: result.moved } });
420
+ }
421
+ }
422
+ if (options.mode === "apply" && release !== null)
423
+ writeApprovalCache(ctx.home, { observedAtMs: ctx.now().getTime(), release });
424
+ const report = {
425
+ schemaVersion: 1,
426
+ mode: options.mode,
427
+ release: release ? { id: release.approvalId, generation: release.generation, releaseLine: release.releaseLine, source: approval.kind === "approved" ? approval.source : "network" } : null,
428
+ unavailable: approval.kind === "unavailable"
429
+ ? {
430
+ reason: approval.reason,
431
+ lastObserved: cached ? { id: cached.release.approvalId, generation: cached.release.generation, observedAtMs: cached.observedAtMs } : null,
432
+ }
433
+ : null,
434
+ crossTrain,
435
+ components,
436
+ projects,
437
+ images: imageResult,
438
+ approved: release,
439
+ };
440
+ return { ...report, exitCode: aggregateExit(report) };
441
+ }
442
+ const STATUS_TEXT = {
443
+ current: () => "current",
444
+ update_available: () => "update available",
445
+ held: (c) => (c.reason === "peer_image_held" ? "held with a held image" : "held by owner's setting"),
446
+ not_installed: () => "not installed",
447
+ unknown: (c) => `unknown (${c.reason})`,
448
+ installed_newer: () => "newer than approved; kept",
449
+ approval_unavailable: () => "no approved version",
450
+ restart_after_cli: () => "restart after CLI update",
451
+ waiting_for_cli: (c) => `waiting for CLI ${c.approved ?? "update"}`,
452
+ stopped: () => "stopped; stays stopped",
453
+ cross_train: () => "kept on this train",
454
+ updated: () => "updated",
455
+ pending: (c) => `waiting (${c.reason})`,
456
+ failed: (c) => `failed (${c.reason})`,
457
+ };
458
+ const UNAVAILABLE_TEXT = {
459
+ signed_out: "sign in with catalyst login.",
460
+ host_unreadable: "this machine's runner could not read it; check that Docker is running.",
461
+ credential_refused: "sign in with catalyst login.",
462
+ unreachable: "the service could not be reached.",
463
+ not_provided: "this workspace does not yet provide component versions.",
464
+ response_invalid: "the service sent a release this CLI cannot read.",
465
+ envelope_mismatch: "the image and component approvals name different releases.",
466
+ };
467
+ const cell = (text, width) => (text.length >= width ? `${text} ` : text.padEnd(width));
468
+ function sdkRows(project) {
469
+ if (project.packages.length === 0)
470
+ return [{ label: `SDK ${project.path}`, installed: "-", approved: "-", status: project.status === "no_sdk" ? "no SDK declared" : `unknown (${project.reason})` }];
471
+ const status = project.applied
472
+ ? project.applied.outcome === "updated"
473
+ ? "updated"
474
+ : project.applied.outcome === "failed"
475
+ ? `failed (${project.applied.detail}); package files put back`
476
+ : `not changed (${project.applied.detail})`
477
+ : project.status === "update_available"
478
+ ? project.policy === "notify"
479
+ ? "notification only"
480
+ : "update available"
481
+ : project.status === "unknown"
482
+ ? `unknown (${project.reason})`
483
+ : project.status === "approval_unavailable"
484
+ ? "no approved version"
485
+ : project.status === "installed_newer"
486
+ ? "newer than approved; kept"
487
+ : "current";
488
+ return project.packages.map((p) => ({
489
+ label: `${SDK_LABELS[p.name] ?? p.name} ${project.path}`,
490
+ installed: p.installed ?? p.locked ?? p.declared,
491
+ approved: p.approved ?? "-",
492
+ status,
493
+ }));
494
+ }
495
+ /** The person-readable report: a header, one row per component and SDK package, then what to run. */
496
+ export function updateReportLines(report) {
497
+ const lines = [];
498
+ const unchanged = report.mode === "check" ? "Nothing was changed." : null;
499
+ if (report.release) {
500
+ lines.push(`Approved release: ${report.release.id}, generation ${report.release.generation}, train ${report.release.releaseLine}`);
501
+ }
502
+ else if (report.unavailable) {
503
+ lines.push(`Approved release unavailable: ${UNAVAILABLE_TEXT[report.unavailable.reason]}`);
504
+ if (report.unavailable.lastObserved)
505
+ lines.push(`Last observed release: ${report.unavailable.lastObserved.id}, generation ${report.unavailable.lastObserved.generation}. It was not used to apply updates.`);
506
+ const anyImage = report.components.some((c) => c.approved !== null);
507
+ if (!anyImage) {
508
+ lines.push(report.mode === "check" ? "Nothing was changed." : "No components were changed.");
509
+ return lines;
510
+ }
511
+ }
512
+ if (report.crossTrain)
513
+ lines.push(`Automatic updates stay on train ${report.crossTrain.supported}. The approved release is on train ${report.crossTrain.approved}; no components were changed.`);
514
+ lines.push(`${cell("Component", 19)}${cell("Installed", 16)}${cell("Approved", 16)}Status`);
515
+ for (const c of report.components)
516
+ lines.push(`${cell(COMPONENT_LABELS[c.id], 19)}${cell(c.installed ?? "-", 16)}${cell(c.approved ?? "-", 16)}${STATUS_TEXT[c.status](c)}`);
517
+ for (const project of report.projects)
518
+ for (const r of sdkRows(project))
519
+ lines.push(`${cell(r.label, 19)}${cell(r.installed, 16)}${cell(r.approved, 16)}${r.status}`);
520
+ if (report.projects.length === 0)
521
+ lines.push("SDK projects: none registered");
522
+ for (const c of report.components)
523
+ if (c.action !== null && (c.status === "update_available" || c.status === "pending"))
524
+ lines.push(`${COMPONENT_LABELS[c.id]}: this CLI does not install ${c.approved ?? "it"} by itself yet. Run: ${c.action}`);
525
+ for (const project of report.projects)
526
+ if (project.command !== null && project.applied?.outcome !== "updated")
527
+ lines.push(`Update this project's SDK: ${project.command}`);
528
+ // CTC-6741: a failed CLI move leaves the running version in place; say so, and when it is tried again.
529
+ const cli = report.components.find((c) => c.id === "cli");
530
+ if (cli?.status === "failed")
531
+ lines.push(`CLI: the update to ${cli.approved ?? "the approved version"} failed (${cli.reason}). You keep running ${cli.installed ?? "the installed version"}. The next release check tries again: within 15 minutes when automatic updates are on, or run catalyst update.`);
532
+ const plugin = report.components.find((c) => c.id === "skills-cloud-plugin");
533
+ if (plugin?.status === "updated")
534
+ lines.push(`Cloud skills plugin: run /reload-plugins in Claude Code, or restart it, to load ${plugin.installed ?? plugin.approved}.`);
535
+ if (unchanged)
536
+ lines.push("Checked the approved release; nothing was changed.");
537
+ else {
538
+ const held = report.components.some((c) => c.status === "held" || c.status === "waiting_for_cli");
539
+ lines.push(report.exitCode !== 0
540
+ ? "Release check finished with components that need attention."
541
+ : held
542
+ ? "Release check finished with held components."
543
+ : "Release check finished.");
544
+ }
545
+ return lines;
546
+ }
547
+ /** The report as `--json` prints it: the decision model, without internal engine detail. */
548
+ export function updateReportJson(report) {
549
+ return {
550
+ schemaVersion: report.schemaVersion,
551
+ mode: report.mode,
552
+ release: report.release,
553
+ unavailable: report.unavailable,
554
+ crossTrain: report.crossTrain,
555
+ // CTC-6792: the commit each skills pack was approved at, so the installer's daily job installs
556
+ // exactly that commit. Null when no release was read, or when it is on another train.
557
+ approvedSkills: report.approved !== null && report.crossTrain === null ? report.approved.skills : null,
558
+ components: report.components,
559
+ projects: report.projects.map((p) => ({
560
+ path: p.path,
561
+ registered: p.registered,
562
+ policy: p.policy,
563
+ packageManager: p.packageManager,
564
+ packages: p.packages,
565
+ status: p.status,
566
+ reason: p.reason,
567
+ command: p.command,
568
+ packageManagerCommand: p.packageManagerCommand,
569
+ applied: p.applied,
570
+ })),
571
+ exitCode: report.exitCode,
572
+ };
573
+ }
574
+ /** Every component's effective setting, for `catalyst update policy`. */
575
+ export function policyLines(policy) {
576
+ const lines = ["Automatic updates:"];
577
+ for (const id of UPDATE_COMPONENTS) {
578
+ const p = effectivePolicy(policy, id);
579
+ lines.push(`${cell(COMPONENT_LABELS[id], 19)}${cell(p.enabled ? "on" : "off", 5)}(${p.source})`);
580
+ }
581
+ if (policy.projects.length === 0)
582
+ lines.push("SDK projects: none registered");
583
+ for (const project of policy.projects)
584
+ lines.push(`${cell(`SDK ${project.path}`, 19)}${cell(project.sdk === "auto" ? "on" : "off", 5)}(${project.source}${project.sdk === "notify" ? ", notify only" : ""})`);
585
+ return lines;
586
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.30",
3
+ "version": "0.16.31",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.30 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.31 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.30 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.31 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.30 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.31 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -15,6 +15,14 @@ Global (`-g`) installs go to `~/.agents/skills/`, linked from `~/.claude/skills/
15
15
 
16
16
  `npx skills add --all` replaces same-named directories and links. Before running it on an existing machine, inspect every same-named destination and proceed only when each is absent or a verified, unmodified copy of the intended pack (a lock entry verifies nothing on disk). Resolve any other copy first. Schedule only the installer, never raw add commands.
17
17
 
18
+ ## Keeping each pack current
19
+
20
+ Check installed components with `catalyst update --check --json`. Read each component's installed version, approved version, status and reason.
21
+ Apply enabled updates with `catalyst update` when the person asks you to update. For a component it does not install itself, it prints the exact command; offer that command the same way.
22
+ Keep every saved component hold. Do not turn a hold off without the person's instruction.
23
+ For a project's SDK, run `catalyst update --check --project <absolute project path>` and show the command it names. Run that command only when the person asks to update that project.
24
+ After a plugin update, start a new agent session to load the installed version.
25
+
18
26
  ## Is each pack there
19
27
 
20
28
  `catalyst ready` checks the Cloud pack only. Look for `catalyst-onboard/SKILL.md` (Cloud) and `research-codebase/SKILL.md` (development) in the intended directory.
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.30 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.31 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10