@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 +6 -0
- package/README.md +7 -3
- package/bin/launch.js +28 -0
- package/dist/approved-release.js +210 -0
- package/dist/args.js +6 -0
- package/dist/capabilities.js +3 -0
- package/dist/cli-update-notice.js +7 -4
- package/dist/cli.js +10 -0
- package/dist/published.js +15 -0
- package/dist/ready.js +11 -5
- package/dist/replica.js +43 -6
- package/dist/runner-auto-update.js +3 -1
- package/dist/runner-command.js +92 -11
- package/dist/runner-update.js +72 -16
- package/dist/update-cli.js +234 -0
- package/dist/update-command.js +151 -0
- package/dist/update-inventory.js +96 -0
- package/dist/update-plugin.js +99 -0
- package/dist/update-policy.js +194 -0
- package/dist/update-replica.js +120 -0
- package/dist/update-sdk.js +262 -0
- package/dist/update-skills.js +64 -0
- package/dist/update.js +586 -0
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/skill-sources.md +8 -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/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
|
|
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
|
|
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
|
|
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]",
|
package/dist/capabilities.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 ?? (() =>
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)`);
|