@catalyst-cloud/cli 0.16.29 → 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 +10 -0
- package/README.md +7 -3
- package/bin/launch.js +28 -0
- package/dist/approved-release.js +210 -0
- package/dist/args.js +21 -0
- package/dist/capabilities.js +5 -0
- package/dist/cli-update-notice.js +7 -4
- package/dist/cli.js +19 -0
- package/dist/lease.js +302 -0
- package/dist/onboard-existing.js +12 -3
- package/dist/onboard-next.js +14 -0
- package/dist/onboard-projects.js +3 -3
- package/dist/onboard-repositories.js +10 -3
- package/dist/onboard-ui.js +5 -1
- package/dist/onboard.js +3 -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/setup-onboard-copy.js +41 -2
- 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/run-this-project/references/stalls-and-escalation.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/what-runs-next.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// update-command.ts — `catalyst update [--check] [--json] [--project <path>] [--dir <path>]` and
|
|
2
|
+
// `catalyst update policy [<component> on|off] [--project <path>]` and
|
|
3
|
+
// `catalyst update sdk --project <path>` (CTC-6708). The release check is update.ts; the owner's
|
|
4
|
+
// settings are update-policy.ts. A policy change applies at once and keeps every other component's
|
|
5
|
+
// setting. `update sdk` is the owner's explicit say-so for one update of one project, and changes no
|
|
6
|
+
// setting.
|
|
7
|
+
import { isAbsolute } from "node:path";
|
|
8
|
+
import { flagBool, flagString, positionals } from "./args.js";
|
|
9
|
+
import { UsageError } from "./errors.js";
|
|
10
|
+
import { dockerRunnerUpdateEngine, runnerDir } from "./runner-update.js";
|
|
11
|
+
import { policyLines, readEffectivePolicy, runUpdate, updateReportJson, updateReportLines } from "./update.js";
|
|
12
|
+
import { applySdkUpdate } from "./update-sdk.js";
|
|
13
|
+
import { canonicalProject, effectivePolicy, isUpdateComponent, readUpdatePolicy, setComponentPolicy, setProjectSdkPolicy, UPDATE_COMPONENTS, } from "./update-policy.js";
|
|
14
|
+
const USAGE = "catalyst update [--check] [--project <absolute path>] [--dir <runner directory>] [--json] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path>";
|
|
15
|
+
/** How each component reads in a sentence. */
|
|
16
|
+
const SENTENCE_NAMES = {
|
|
17
|
+
runner: "runner",
|
|
18
|
+
supervisor: "supervisor",
|
|
19
|
+
watchdog: "watchdog",
|
|
20
|
+
cli: "CLI",
|
|
21
|
+
"skills-cloud": "Cloud skills",
|
|
22
|
+
"skills-dev": "Dev skills",
|
|
23
|
+
replica: "local replica",
|
|
24
|
+
};
|
|
25
|
+
function absoluteFlag(args, name) {
|
|
26
|
+
const value = flagString(args, name);
|
|
27
|
+
if (value !== undefined && !isAbsolute(value))
|
|
28
|
+
throw new UsageError(`--${name} must be an absolute path`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function cmdPolicy(parts, args, ctx) {
|
|
32
|
+
const project = absoluteFlag(args, "project");
|
|
33
|
+
if (flagBool(args, "check") || args.flags.dir !== undefined)
|
|
34
|
+
throw new UsageError("--check and --dir are not valid with update policy");
|
|
35
|
+
if (parts.length === 1) {
|
|
36
|
+
if (project !== undefined)
|
|
37
|
+
throw new UsageError("update policy lists every setting; --project goes with update policy sdk on|off");
|
|
38
|
+
const policy = readEffectivePolicy(ctx);
|
|
39
|
+
if (args.json)
|
|
40
|
+
ctx.stdout(JSON.stringify({
|
|
41
|
+
components: UPDATE_COMPONENTS.map((id) => ({ id, ...effectivePolicy(policy, id) })),
|
|
42
|
+
projects: policy.projects.map((p) => ({ path: p.path, sdk: p.sdk, source: p.source })),
|
|
43
|
+
}));
|
|
44
|
+
else
|
|
45
|
+
for (const line of policyLines(policy))
|
|
46
|
+
ctx.stdout(line);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const [, name, value] = parts;
|
|
50
|
+
if (parts.length !== 3 || (value !== "on" && value !== "off") || name === undefined)
|
|
51
|
+
throw new UsageError(USAGE);
|
|
52
|
+
const enabled = value === "on";
|
|
53
|
+
if (name === "sdk") {
|
|
54
|
+
if (project === undefined)
|
|
55
|
+
throw new UsageError("update policy sdk needs --project <absolute path>");
|
|
56
|
+
const saved = setProjectSdkPolicy(ctx.home, project, enabled ? "auto" : "notify");
|
|
57
|
+
const text = enabled
|
|
58
|
+
? `Automatic SDK updates are on for ${saved.path}. Release checks may update its package files.`
|
|
59
|
+
: `Automatic SDK updates are off for ${saved.path}. Release checks will only report available updates.`;
|
|
60
|
+
if (args.json)
|
|
61
|
+
ctx.stdout(JSON.stringify({ project: saved.path, sdk: saved.sdk, source: saved.source }));
|
|
62
|
+
else
|
|
63
|
+
ctx.stdout(text);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
if (project !== undefined)
|
|
67
|
+
throw new UsageError("--project goes with update policy sdk on|off");
|
|
68
|
+
if (!isUpdateComponent(name))
|
|
69
|
+
throw new UsageError(`unknown component: ${name}. Use one of ${UPDATE_COMPONENTS.join(", ")} or sdk`);
|
|
70
|
+
setComponentPolicy(ctx.home, name, enabled);
|
|
71
|
+
if (args.json)
|
|
72
|
+
ctx.stdout(JSON.stringify({ id: name, enabled, source: "owner" }));
|
|
73
|
+
else
|
|
74
|
+
ctx.stdout(`Automatic ${SENTENCE_NAMES[name]} updates are ${value}. Other components keep their settings.`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
async function cmdSdk(parts, args, ctx, deps) {
|
|
78
|
+
if (parts.length !== 1)
|
|
79
|
+
throw new UsageError(USAGE);
|
|
80
|
+
if (flagBool(args, "check"))
|
|
81
|
+
throw new UsageError("update sdk updates the project; to only look, run catalyst update --check --project <path>");
|
|
82
|
+
const flag = absoluteFlag(args, "project");
|
|
83
|
+
if (flag === undefined)
|
|
84
|
+
throw new UsageError("update sdk needs --project <absolute path>");
|
|
85
|
+
const project = canonicalProject(flag).path;
|
|
86
|
+
const dirFlag = absoluteFlag(args, "dir");
|
|
87
|
+
const report = await runUpdate(ctx, {
|
|
88
|
+
mode: "check",
|
|
89
|
+
imagesDir: dirFlag ?? runnerDir(ctx),
|
|
90
|
+
project,
|
|
91
|
+
engine: () => deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }),
|
|
92
|
+
});
|
|
93
|
+
if (report.approved === null || report.crossTrain !== null) {
|
|
94
|
+
const lines = report.crossTrain
|
|
95
|
+
? [`The approved release is on train ${report.crossTrain.approved}; SDK updates stay on train ${report.crossTrain.supported}.`, "Nothing was changed."]
|
|
96
|
+
: updateReportLines(report).filter((line) => line.startsWith("Approved release unavailable") || line === "Nothing was changed.");
|
|
97
|
+
if (args.json)
|
|
98
|
+
ctx.stdout(JSON.stringify({ project, outcome: "refused", detail: report.crossTrain ? "cross_train" : (report.unavailable?.reason ?? "approval_unavailable"), moved: [] }));
|
|
99
|
+
else
|
|
100
|
+
for (const line of lines)
|
|
101
|
+
ctx.stdout(line);
|
|
102
|
+
return report.crossTrain ? 0 : 1;
|
|
103
|
+
}
|
|
104
|
+
const result = applySdkUpdate(ctx.home, project, report.approved, deps.sdkExec);
|
|
105
|
+
const registered = readUpdatePolicy(ctx.home).projects.find((p) => p.path === project);
|
|
106
|
+
if (args.json) {
|
|
107
|
+
ctx.stdout(JSON.stringify({ project, outcome: result.outcome, detail: result.detail, moved: result.moved }));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const manager = result.project.packageManager ?? "its package manager";
|
|
111
|
+
if (result.outcome === "updated")
|
|
112
|
+
for (const move of result.moved)
|
|
113
|
+
ctx.stdout(`${move.name} in ${project}: updated from ${move.from} to ${move.to} with ${manager}.`);
|
|
114
|
+
else if (result.outcome === "current")
|
|
115
|
+
ctx.stdout(`SDK ${project}: already on the approved version.`);
|
|
116
|
+
else if (result.outcome === "refused")
|
|
117
|
+
ctx.stdout(`SDK ${project}: not changed (${result.detail}).`);
|
|
118
|
+
else
|
|
119
|
+
ctx.stdout(`SDK ${project}: the update failed (${result.detail}), and its package files were put back. If ${manager} left a partial install, run ${manager} install there.`);
|
|
120
|
+
ctx.stdout(`Automatic SDK updates remain ${registered?.sdk === "auto" ? "on" : "off"} for ${project}.`);
|
|
121
|
+
}
|
|
122
|
+
return result.outcome === "updated" || result.outcome === "current" ? 0 : 1;
|
|
123
|
+
}
|
|
124
|
+
export async function cmdUpdate(args, ctx, deps = {}) {
|
|
125
|
+
const parts = positionals(args);
|
|
126
|
+
if (parts[0] === "policy")
|
|
127
|
+
return cmdPolicy(parts, args, ctx);
|
|
128
|
+
if (parts[0] === "sdk")
|
|
129
|
+
return cmdSdk(parts, args, ctx, deps);
|
|
130
|
+
if (parts.length > 0)
|
|
131
|
+
throw new UsageError(USAGE);
|
|
132
|
+
const dirFlag = absoluteFlag(args, "dir");
|
|
133
|
+
const project = absoluteFlag(args, "project");
|
|
134
|
+
const report = await runUpdate(ctx, {
|
|
135
|
+
mode: flagBool(args, "check") ? "check" : "apply",
|
|
136
|
+
imagesDir: dirFlag ?? runnerDir(ctx),
|
|
137
|
+
// Read the project where it really is; an inspection never registers it.
|
|
138
|
+
...(project !== undefined ? { project: canonicalProject(project).path } : {}),
|
|
139
|
+
engine: () => deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }),
|
|
140
|
+
...(deps.sdkExec ? { sdkExec: deps.sdkExec } : {}),
|
|
141
|
+
...(deps.cliApply ? { cliApply: deps.cliApply } : {}),
|
|
142
|
+
...(deps.replicaRestart ? { replicaRestart: deps.replicaRestart } : {}),
|
|
143
|
+
...(deps.packApply ? { packApply: deps.packApply } : {}),
|
|
144
|
+
});
|
|
145
|
+
if (args.json)
|
|
146
|
+
ctx.stdout(JSON.stringify(updateReportJson(report)));
|
|
147
|
+
else
|
|
148
|
+
for (const line of updateReportLines(report))
|
|
149
|
+
ctx.stdout(line);
|
|
150
|
+
return report.exitCode;
|
|
151
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// update-inventory.ts — what this install has, read without changing anything (CTC-6708). Each
|
|
2
|
+
// component is installed (with the version or image it runs), absent, or unknown with a reason;
|
|
3
|
+
// unknown is never reported as current. Reading it starts no process, contacts no service, and a
|
|
4
|
+
// machine with no runner installation never touches Docker.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { exactVersion } from "./approved-release.js";
|
|
8
|
+
import { loadConfig, packageRoot, readManifest, updatePackageName } from "./config.js";
|
|
9
|
+
import { parseEnvText } from "./onboard-runner.js";
|
|
10
|
+
import { replicaStatus, writerIsRunning } from "./replica.js";
|
|
11
|
+
import { readReleaseState, RELEASE_COMPONENTS, RELEASE_ENV_KEYS, releaseReference, } from "./runner-update.js";
|
|
12
|
+
import { installedBundleVersion, resolveSkillsDir, skillsSourceDir } from "./skills.js";
|
|
13
|
+
import { cloudPluginInventory } from "./update-plugin.js";
|
|
14
|
+
export function imageInventory(dir) {
|
|
15
|
+
const envPath = join(dir, ".env");
|
|
16
|
+
if (!existsSync(envPath) || !existsSync(join(dir, "compose.yaml")))
|
|
17
|
+
return { installed: false, dir };
|
|
18
|
+
let env = {};
|
|
19
|
+
try {
|
|
20
|
+
env = parseEnvText(readFileSync(envPath, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
env = {};
|
|
24
|
+
}
|
|
25
|
+
const references = Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, releaseReference(c, env[RELEASE_ENV_KEYS[c]])]));
|
|
26
|
+
return { installed: true, dir, references, generation: readReleaseState(dir).generation };
|
|
27
|
+
}
|
|
28
|
+
function bundledSkillNames() {
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(skillsSourceDir(), { withFileTypes: true })
|
|
31
|
+
.filter((e) => e.isDirectory() && existsSync(join(skillsSourceDir(), e.name, "SKILL.md")))
|
|
32
|
+
.map((e) => e.name);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The Cloud pack as installed in the agent skills directory, read from each skill's provenance line. */
|
|
39
|
+
export function cloudPackInventory(ctx, cfg) {
|
|
40
|
+
const dir = resolveSkillsDir({}, ctx, cfg);
|
|
41
|
+
const found = installedBundleVersion(dir, bundledSkillNames());
|
|
42
|
+
if (found.version !== null) {
|
|
43
|
+
const version = exactVersion(found.version);
|
|
44
|
+
return version ? { state: "installed", version, dir } : { state: "unknown", reason: "version_unreadable", dir };
|
|
45
|
+
}
|
|
46
|
+
if (found.unstamped.length > 0)
|
|
47
|
+
return { state: "unknown", reason: "version_unstamped", dir };
|
|
48
|
+
return { state: "not_installed", dir };
|
|
49
|
+
}
|
|
50
|
+
/** The Dev pack ships from its own repository with no provenance stamp this CLI reads, so its
|
|
51
|
+
* presence is known (its `research-codebase` skill, as the onboarding guide checks) but its version
|
|
52
|
+
* is not. Unknown is never reported as current. */
|
|
53
|
+
export function devPackInventory(ctx, cfg) {
|
|
54
|
+
const dir = resolveSkillsDir({}, ctx, cfg);
|
|
55
|
+
return existsSync(join(dir, "research-codebase", "SKILL.md"))
|
|
56
|
+
? { state: "unknown", reason: "version_unrecorded", dir }
|
|
57
|
+
: { state: "not_installed", dir };
|
|
58
|
+
}
|
|
59
|
+
export function replicaInventory(ctx, cfg) {
|
|
60
|
+
if (!cfg)
|
|
61
|
+
return { state: "not_installed" };
|
|
62
|
+
let status;
|
|
63
|
+
try {
|
|
64
|
+
status = replicaStatus(ctx, cfg);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
|
|
68
|
+
}
|
|
69
|
+
if (status.dbPath === null || status.verdict === "not-configured" || status.verdict === "absent")
|
|
70
|
+
return { state: "not_installed" };
|
|
71
|
+
if (!writerIsRunning(status))
|
|
72
|
+
return { state: "stopped", dbPath: status.dbPath };
|
|
73
|
+
const version = exactVersion(status.writer?.version);
|
|
74
|
+
return { state: "running", dbPath: status.dbPath, version, pid: status.writer?.pid ?? status.lockPid ?? status.pidfilePid };
|
|
75
|
+
}
|
|
76
|
+
/** Everything this install has. `imagesDir` is the runner installation to read. */
|
|
77
|
+
export function readInventory(ctx, imagesDir) {
|
|
78
|
+
let cfg = null;
|
|
79
|
+
try {
|
|
80
|
+
cfg = loadConfig(ctx.home);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
cfg = null;
|
|
84
|
+
}
|
|
85
|
+
const root = packageRoot();
|
|
86
|
+
return {
|
|
87
|
+
cli: { version: readManifest().version, packageName: updatePackageName(root), root },
|
|
88
|
+
images: imageInventory(imagesDir),
|
|
89
|
+
skills: {
|
|
90
|
+
cloud: cloudPackInventory(ctx, cfg),
|
|
91
|
+
dev: devPackInventory(ctx, cfg),
|
|
92
|
+
},
|
|
93
|
+
plugin: cloudPluginInventory(ctx.home, ctx.env),
|
|
94
|
+
replica: replicaInventory(ctx, cfg),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// update-plugin.ts — the Claude plugin form of the Cloud skills pack moves with the release check
|
|
2
|
+
// (CTC-6791). Claude Code installs the plugin from its own marketplace checkout of this repository,
|
|
3
|
+
// which serves the repository's head, so the release check moves it only when that checkout carries
|
|
4
|
+
// exactly the approved version: it refreshes the marketplace, reads the plugin version the checkout
|
|
5
|
+
// now serves, and asks Claude Code to update the plugin only when that version is the approved one.
|
|
6
|
+
// Claude Code has no way to install a plugin at a pinned version, so a marketplace ahead of the
|
|
7
|
+
// approval leaves the plugin where it is. The 15-minute schedule finds `claude` at the absolute path
|
|
8
|
+
// recorded when it was set up, then on PATH, then in Claude Code's usual install locations; only when
|
|
9
|
+
// none has it is nothing changed, and the report names the command.
|
|
10
|
+
import { accessSync, constants, readFileSync } from "node:fs";
|
|
11
|
+
import { delimiter, join } from "node:path";
|
|
12
|
+
import { exactVersion } from "./approved-release.js";
|
|
13
|
+
import { spawnCliCommand } from "./update-cli.js";
|
|
14
|
+
export const CLOUD_MARKETPLACE = "catalyst-cloud";
|
|
15
|
+
export const CLOUD_PLUGIN_ID = `catalyst@${CLOUD_MARKETPLACE}`;
|
|
16
|
+
/** The one command that moves the plugin, for an owner whose release check cannot run Claude Code. */
|
|
17
|
+
export const CLOUD_PLUGIN_COMMAND = `claude plugin marketplace update ${CLOUD_MARKETPLACE} && claude plugin update ${CLOUD_PLUGIN_ID}`;
|
|
18
|
+
export function claudeConfigDir(home, env) {
|
|
19
|
+
return env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
|
20
|
+
}
|
|
21
|
+
function readJson(path) {
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
25
|
+
/** The Cloud plugin as Claude Code records it: its user-scope install when there is one, else the first. */
|
|
26
|
+
export function cloudPluginInventory(home, env) {
|
|
27
|
+
let registry;
|
|
28
|
+
try {
|
|
29
|
+
registry = readJson(join(claudeConfigDir(home, env), "plugins", "installed_plugins.json"));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return err.code === "ENOENT" ? { state: "not_installed" } : { state: "unknown", reason: "plugin_registry_unreadable" };
|
|
33
|
+
}
|
|
34
|
+
const entries = isRecord(registry) && isRecord(registry.plugins) ? registry.plugins[CLOUD_PLUGIN_ID] : undefined;
|
|
35
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
36
|
+
return { state: "not_installed" };
|
|
37
|
+
const entry = entries.find((e) => isRecord(e) && e.scope === "user") ?? entries[0];
|
|
38
|
+
const version = isRecord(entry) ? exactVersion(entry.version) : null;
|
|
39
|
+
if (version === null)
|
|
40
|
+
return { state: "unknown", reason: "version_unreadable" };
|
|
41
|
+
return { state: "installed", version, scope: isRecord(entry) && typeof entry.scope === "string" ? entry.scope : "user", claude: resolveClaude(home, env) };
|
|
42
|
+
}
|
|
43
|
+
/** The plugin version the marketplace checkout serves now, or null when it cannot be read. */
|
|
44
|
+
function servedPluginVersion(home, env) {
|
|
45
|
+
const plugins = join(claudeConfigDir(home, env), "plugins");
|
|
46
|
+
let dir = join(plugins, "marketplaces", CLOUD_MARKETPLACE);
|
|
47
|
+
try {
|
|
48
|
+
const known = readJson(join(plugins, "known_marketplaces.json"));
|
|
49
|
+
const entry = isRecord(known) ? known[CLOUD_MARKETPLACE] : undefined;
|
|
50
|
+
if (isRecord(entry) && typeof entry.installLocation === "string")
|
|
51
|
+
dir = entry.installLocation;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// No record of where it lives: Claude Code's default checkout directory.
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const manifest = readJson(join(dir, ".claude-plugin", "plugin.json"));
|
|
58
|
+
return isRecord(manifest) ? exactVersion(manifest.version) : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The schedule's record of the `claude` command found when it was set up. */
|
|
65
|
+
export const CLAUDE_PATH_ENV = "CATALYST_CLAUDE_PATH";
|
|
66
|
+
const executable = (path) => {
|
|
67
|
+
try {
|
|
68
|
+
accessSync(path, constants.X_OK);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** The `claude` command: the schedule's recorded path, then PATH, then Claude Code's usual install locations. */
|
|
76
|
+
export function resolveClaude(home, env) {
|
|
77
|
+
const recorded = env[CLAUDE_PATH_ENV];
|
|
78
|
+
const onPath = (env.PATH ?? "").split(delimiter).filter((d) => d.startsWith("/")).map((d) => join(d, "claude"));
|
|
79
|
+
// Homebrew, /usr/local/bin and node's own directory are already on the schedule's PATH (scheduledPath).
|
|
80
|
+
const usual = [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
|
|
81
|
+
return [...(recorded?.startsWith("/") ? [recorded] : []), ...onPath, ...usual].find(executable) ?? null;
|
|
82
|
+
}
|
|
83
|
+
/** Move the Cloud plugin to exact version `target` through Claude Code, when its marketplace serves it. */
|
|
84
|
+
export function applyCloudPluginUpdate(home, env, target, scope, deps = {}) {
|
|
85
|
+
const claude = deps.claudePath === undefined ? resolveClaude(home, env) : deps.claudePath;
|
|
86
|
+
if (claude === null)
|
|
87
|
+
return { outcome: "no_claude", detail: "claude_not_found", version: null };
|
|
88
|
+
const exec = deps.exec ?? spawnCliCommand;
|
|
89
|
+
if (exec([claude, "plugin", "marketplace", "update", CLOUD_MARKETPLACE], home, env).status !== 0)
|
|
90
|
+
return { outcome: "failed", detail: "marketplace_update_failed", version: null };
|
|
91
|
+
if (servedPluginVersion(home, env) !== target)
|
|
92
|
+
return { outcome: "waiting", detail: "marketplace_not_approved", version: null };
|
|
93
|
+
const updated = exec([claude, "plugin", "update", CLOUD_PLUGIN_ID, "--scope", scope], home, env).status === 0;
|
|
94
|
+
const now = cloudPluginInventory(home, env);
|
|
95
|
+
const version = now.state === "installed" ? now.version : null;
|
|
96
|
+
if (!updated)
|
|
97
|
+
return { outcome: "failed", detail: "update_failed", version };
|
|
98
|
+
return version === target ? { outcome: "updated", detail: "updated", version } : { outcome: "failed", detail: "verify_failed", version };
|
|
99
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// update-policy.ts — which installed components update by themselves (CTC-6708). Every component
|
|
2
|
+
// updates by default; an owner turns one off only with an explicit setting, kept in
|
|
3
|
+
// ~/.config/catalyst-cloud/updates.json (mode 0600, apart from the sign-in record). An absent entry
|
|
4
|
+
// means on, so only explicit or migrated choices are stored.
|
|
5
|
+
//
|
|
6
|
+
// A project's SDK is the one exception: Catalyst never edits a customer's repository unasked, so a
|
|
7
|
+
// registered project is notify-only until its owner turns its SDK updates on. Project paths are kept
|
|
8
|
+
// by their real path and the root's device and inode, so a later symlink or moved directory cannot
|
|
9
|
+
// redirect an opt-in to a different repository.
|
|
10
|
+
//
|
|
11
|
+
// Writes take a short exclusive lock and replace the file by rename, so two setting changes at once
|
|
12
|
+
// never lose one of them and a reader never sees half a file.
|
|
13
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { isAbsolute, join } from "node:path";
|
|
15
|
+
import { configDirFor } from "./config.js";
|
|
16
|
+
import { CliError, UsageError } from "./errors.js";
|
|
17
|
+
import { isRecord, writePrivateAtomic } from "./runner-update.js";
|
|
18
|
+
export const UPDATE_POLICY_FILE = "updates.json";
|
|
19
|
+
const LOCK_STALE_MS = 30_000;
|
|
20
|
+
const LOCK_WAIT_MS = 5_000;
|
|
21
|
+
/** The installed components a release check keeps current. The SDK is per project, not here. */
|
|
22
|
+
export const UPDATE_COMPONENTS = ["runner", "supervisor", "watchdog", "cli", "skills-cloud", "skills-dev", "replica"];
|
|
23
|
+
export function updatePolicyPathFor(home) {
|
|
24
|
+
return join(configDirFor(home), UPDATE_POLICY_FILE);
|
|
25
|
+
}
|
|
26
|
+
const emptyPolicy = () => ({ version: 1, components: {}, projects: [] });
|
|
27
|
+
export function isUpdateComponent(value) {
|
|
28
|
+
return UPDATE_COMPONENTS.includes(value);
|
|
29
|
+
}
|
|
30
|
+
/** Read the saved preferences. A missing file is the default (everything on). A file that is not a
|
|
31
|
+
* valid record is an error, never silently "everything on": that would undo an owner's hold. */
|
|
32
|
+
export function readUpdatePolicy(home) {
|
|
33
|
+
const path = updatePolicyPathFor(home);
|
|
34
|
+
if (!existsSync(path))
|
|
35
|
+
return emptyPolicy();
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new CliError(`update settings at ${path} are not valid JSON; fix or remove the file`, "update-policy-corrupt", 1);
|
|
42
|
+
}
|
|
43
|
+
const policy = parsePolicy(parsed);
|
|
44
|
+
if (!policy)
|
|
45
|
+
throw new CliError(`update settings at ${path} are not a settings record this CLI can read; fix or remove the file`, "update-policy-corrupt", 1);
|
|
46
|
+
return policy;
|
|
47
|
+
}
|
|
48
|
+
function parsePolicy(input) {
|
|
49
|
+
if (!isRecord(input) || input.version !== 1)
|
|
50
|
+
return null;
|
|
51
|
+
const policy = emptyPolicy();
|
|
52
|
+
if (input.components !== undefined) {
|
|
53
|
+
if (!isRecord(input.components))
|
|
54
|
+
return null;
|
|
55
|
+
for (const [name, value] of Object.entries(input.components)) {
|
|
56
|
+
if (!isUpdateComponent(name))
|
|
57
|
+
return null;
|
|
58
|
+
if (!isRecord(value) || typeof value.enabled !== "boolean")
|
|
59
|
+
return null;
|
|
60
|
+
if (value.source !== "owner" && value.source !== "migrated")
|
|
61
|
+
return null;
|
|
62
|
+
policy.components[name] = { enabled: value.enabled, source: value.source };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (input.projects !== undefined) {
|
|
66
|
+
if (!Array.isArray(input.projects))
|
|
67
|
+
return null;
|
|
68
|
+
for (const value of input.projects) {
|
|
69
|
+
if (!isRecord(value) || typeof value.path !== "string" || !isAbsolute(value.path))
|
|
70
|
+
return null;
|
|
71
|
+
if (value.sdk !== "notify" && value.sdk !== "auto")
|
|
72
|
+
return null;
|
|
73
|
+
if (value.source !== "owner" && value.source !== "migrated")
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof value.dev !== "number" || typeof value.ino !== "number")
|
|
76
|
+
return null;
|
|
77
|
+
policy.projects.push({ path: value.path, sdk: value.sdk, source: value.source, dev: value.dev, ino: value.ino });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return policy;
|
|
81
|
+
}
|
|
82
|
+
/** The components an installer's daily update kept current: the CLI and both skills packs. */
|
|
83
|
+
export const DAILY_UPDATE_COMPONENTS = ["cli", "skills-cloud", "skills-dev"];
|
|
84
|
+
/**
|
|
85
|
+
* An owner who turned the installer's daily update off chose not to have the CLI and the skills
|
|
86
|
+
* packs moved for them. Until they save a setting of their own for one of those, it reads as held,
|
|
87
|
+
* with source `migrated`. A saved setting always wins, and nothing is written, so a check stays
|
|
88
|
+
* read-only and a later `update policy … on` lifts it. A missing record is no evidence: no hold.
|
|
89
|
+
*/
|
|
90
|
+
export function withMigratedHolds(policy, dailyUpdateOff) {
|
|
91
|
+
if (!dailyUpdateOff)
|
|
92
|
+
return policy;
|
|
93
|
+
const components = { ...policy.components };
|
|
94
|
+
for (const id of DAILY_UPDATE_COMPONENTS)
|
|
95
|
+
components[id] ??= { enabled: false, source: "migrated" };
|
|
96
|
+
return { ...policy, components };
|
|
97
|
+
}
|
|
98
|
+
export function effectivePolicy(policy, component) {
|
|
99
|
+
const saved = policy.components[component];
|
|
100
|
+
return saved ? { enabled: saved.enabled, source: saved.source } : { enabled: true, source: "default" };
|
|
101
|
+
}
|
|
102
|
+
/** A project path as the policy stores it: absolute, real, and a directory. */
|
|
103
|
+
export function canonicalProject(path) {
|
|
104
|
+
if (!isAbsolute(path))
|
|
105
|
+
throw new UsageError("--project must be an absolute path");
|
|
106
|
+
let real;
|
|
107
|
+
try {
|
|
108
|
+
real = realpathSync(path);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new CliError(`no project at ${path}`, "project-missing", 1);
|
|
112
|
+
}
|
|
113
|
+
const stat = statSync(real);
|
|
114
|
+
if (!stat.isDirectory())
|
|
115
|
+
throw new CliError(`${path} is not a directory`, "project-missing", 1);
|
|
116
|
+
return { path: real, dev: stat.dev, ino: stat.ino };
|
|
117
|
+
}
|
|
118
|
+
/** Whether a registered project is still the directory its owner registered. */
|
|
119
|
+
export function projectIdentityHolds(project) {
|
|
120
|
+
try {
|
|
121
|
+
if (realpathSync(project.path) !== project.path)
|
|
122
|
+
return false;
|
|
123
|
+
const stat = statSync(project.path);
|
|
124
|
+
return stat.isDirectory() && stat.dev === project.dev && stat.ino === project.ino;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function takePolicyLock(lockPath, nowMs) {
|
|
131
|
+
const deadline = nowMs() + LOCK_WAIT_MS;
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
closeSync(openSync(lockPath, "wx", 0o600));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (!(err instanceof Error && "code" in err && err.code === "EEXIST"))
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
if (nowMs() - statSync(lockPath).mtimeMs > LOCK_STALE_MS)
|
|
143
|
+
unlinkSync(lockPath);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// gone already: try again
|
|
147
|
+
}
|
|
148
|
+
if (nowMs() > deadline)
|
|
149
|
+
throw new CliError("another command is changing the update settings; try again", "update-policy-busy", 1);
|
|
150
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Change the saved preferences under the lock: read the current file, apply `change`, replace it. */
|
|
154
|
+
export function updateUpdatePolicy(home, change, nowMs = Date.now) {
|
|
155
|
+
const dir = configDirFor(home);
|
|
156
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
157
|
+
const path = updatePolicyPathFor(home);
|
|
158
|
+
const lockPath = `${path}.lock`;
|
|
159
|
+
takePolicyLock(lockPath, nowMs);
|
|
160
|
+
try {
|
|
161
|
+
const policy = readUpdatePolicy(home);
|
|
162
|
+
change(policy);
|
|
163
|
+
writePrivateAtomic(path, `${JSON.stringify(policy, null, 2)}\n`);
|
|
164
|
+
return policy;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
try {
|
|
168
|
+
unlinkSync(lockPath);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// already released
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function setComponentPolicy(home, component, enabled) {
|
|
176
|
+
return updateUpdatePolicy(home, (policy) => {
|
|
177
|
+
policy.components[component] = { enabled, source: "owner" };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** Register a project (or change its SDK setting). The stored path is the real path. */
|
|
181
|
+
export function setProjectSdkPolicy(home, path, sdk) {
|
|
182
|
+
const project = canonicalProject(path);
|
|
183
|
+
let saved = null;
|
|
184
|
+
updateUpdatePolicy(home, (policy) => {
|
|
185
|
+
const entry = { ...project, sdk, source: "owner" };
|
|
186
|
+
const at = policy.projects.findIndex((p) => p.path === project.path);
|
|
187
|
+
if (at === -1)
|
|
188
|
+
policy.projects.push(entry);
|
|
189
|
+
else
|
|
190
|
+
policy.projects[at] = entry;
|
|
191
|
+
saved = entry;
|
|
192
|
+
});
|
|
193
|
+
return saved ?? { ...project, sdk, source: "owner" };
|
|
194
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// update-replica.ts — a running local replica comes back on the approved CLI with its data
|
|
2
|
+
// (CTC-6708). A detached writer keeps the code it started with, so after the CLI moves, a release
|
|
3
|
+
// check stops the writer it can identify, waits until that process has exited and released the
|
|
4
|
+
// writer lock, then starts a new writer through the person's own `catalyst` command (which runs the
|
|
5
|
+
// selected version) on the same database file. Nothing here deletes, moves or reseeds the database:
|
|
6
|
+
// the new writer resumes from the rows and cursor the old one left.
|
|
7
|
+
//
|
|
8
|
+
// It never starts a second writer: when the old one does not exit inside the deadline, or cannot be
|
|
9
|
+
// identified, the restart stops there with a reason and the next check tries again. Before it stops
|
|
10
|
+
// a writer it records that restart (replica-restart.json), and clears the record only once the new
|
|
11
|
+
// writer reports its version, so a writer that exits after the deadline is started by the next check
|
|
12
|
+
// instead of being taken for one its owner stopped. A replica that was stopped stays stopped.
|
|
13
|
+
// Success is read back from the new writer's own record of the CLI version it runs.
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { configDirFor } from "./config.js";
|
|
17
|
+
import { pendingRestart, pendingRestartPathFor, pidAlive, pidfilePath, replicaStatus, writerIsRunning } from "./replica.js";
|
|
18
|
+
import { ACTIVE_RUNTIME_ENV } from "./update-cli.js";
|
|
19
|
+
/** Whether a restart this CLI began for `dbPath` has not finished: the writer was stopped by it. */
|
|
20
|
+
export function restartPending(home, dbPath) {
|
|
21
|
+
return pendingRestart(home, dbPath) !== null;
|
|
22
|
+
}
|
|
23
|
+
/** A writer's command line runs `replica start` (bin/launch.js keeps it in one process). */
|
|
24
|
+
export function isReplicaWriter(pid) {
|
|
25
|
+
const ps = spawnSync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8", timeout: 5_000 });
|
|
26
|
+
return ps.status === 0 && /\sreplica\s+start(\s|$)/.test(ps.stdout);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The command that starts the new writer. A scheduled run on Linux is a systemd oneshot service, and
|
|
30
|
+
* when it ends systemd stops every process left in its control group, a detached one included; a
|
|
31
|
+
* transient scope of the writer's own keeps it running after the release check ends.
|
|
32
|
+
*/
|
|
33
|
+
export function replicaStartCommand(argv, env, platform = process.platform) {
|
|
34
|
+
const node = [process.execPath, ...argv];
|
|
35
|
+
return platform === "linux" && env.INVOCATION_ID ? ["systemd-run", "--user", "--scope", "--quiet", "--", ...node] : node;
|
|
36
|
+
}
|
|
37
|
+
const startDetached = (argv, env) => {
|
|
38
|
+
const run = (command) => {
|
|
39
|
+
const [cmd, ...args] = command;
|
|
40
|
+
if (cmd === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", env });
|
|
43
|
+
child.on("error", () => { });
|
|
44
|
+
child.unref();
|
|
45
|
+
return child.pid;
|
|
46
|
+
};
|
|
47
|
+
// No systemd-run on PATH: start it directly, as `replica start --detach` does.
|
|
48
|
+
const pid = run(replicaStartCommand(argv, env)) ?? run([process.execPath, ...argv]);
|
|
49
|
+
if (typeof pid !== "number")
|
|
50
|
+
throw new Error("detached spawn returned no pid");
|
|
51
|
+
return pid;
|
|
52
|
+
};
|
|
53
|
+
/** Restart a running replica writer on the CLI the stable command now selects, expecting `target`. */
|
|
54
|
+
export async function restartReplica(ctx, cfg, target, deps) {
|
|
55
|
+
const status = deps.status ?? (() => replicaStatus(ctx, cfg));
|
|
56
|
+
const alive = deps.alive ?? pidAlive;
|
|
57
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
58
|
+
const timeoutMs = deps.timeoutMs ?? 60_000;
|
|
59
|
+
const pollMs = deps.pollMs ?? 250;
|
|
60
|
+
const waitFor = async (done) => {
|
|
61
|
+
for (let waited = 0;; waited += pollMs) {
|
|
62
|
+
if (done())
|
|
63
|
+
return true;
|
|
64
|
+
if (waited >= timeoutMs)
|
|
65
|
+
return false;
|
|
66
|
+
await sleep(pollMs);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const before = status();
|
|
70
|
+
if (before.dbPath === null)
|
|
71
|
+
return { outcome: "stopped", detail: "stopped", version: null };
|
|
72
|
+
const dbPath = before.dbPath;
|
|
73
|
+
// Stopped: by its owner (it stays stopped), or by an earlier restart that did not finish (it starts).
|
|
74
|
+
if (!writerIsRunning(before) && !restartPending(ctx.home, dbPath))
|
|
75
|
+
return { outcome: "stopped", detail: "stopped", version: null };
|
|
76
|
+
if (writerIsRunning(before)) {
|
|
77
|
+
// The writer is the process holding the lock (or recorded in its own state, or the pidfile).
|
|
78
|
+
const pid = before.lockPid ?? before.writer?.pid ?? before.pidfilePid;
|
|
79
|
+
if (pid === null || !alive(pid) || !(deps.identify ?? isReplicaWriter)(pid))
|
|
80
|
+
return { outcome: "failed", detail: "writer_unidentified", version: before.writer?.version ?? null };
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(configDirFor(ctx.home), { recursive: true, mode: 0o700 });
|
|
83
|
+
writeFileSync(pendingRestartPathFor(ctx.home), `${JSON.stringify({ dbPath, target })}\n`, { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Without the record a late exit would read as an owner's stop: do not stop the writer.
|
|
87
|
+
return { outcome: "failed", detail: "start_failed", version: before.writer?.version ?? null };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
(deps.signal ?? ((p) => process.kill(p, "SIGTERM")))(pid);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
rmSync(pendingRestartPathFor(ctx.home), { force: true });
|
|
94
|
+
return { outcome: "failed", detail: "writer_unidentified", version: before.writer?.version ?? null };
|
|
95
|
+
}
|
|
96
|
+
const exited = await waitFor(() => !alive(pid) && !writerIsRunning(status()));
|
|
97
|
+
if (!exited)
|
|
98
|
+
return { outcome: "failed", detail: "stop_timeout", version: before.writer?.version ?? null };
|
|
99
|
+
}
|
|
100
|
+
// Without a staged check's marker, so the person's own command selects the version to run.
|
|
101
|
+
const env = { ...ctx.env };
|
|
102
|
+
delete env[ACTIVE_RUNTIME_ENV];
|
|
103
|
+
let started;
|
|
104
|
+
try {
|
|
105
|
+
started = (deps.start ?? startDetached)([deps.launcher, "replica", "start", "--db", dbPath], env);
|
|
106
|
+
writeFileSync(pidfilePath(dbPath), `${started}\n`);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { outcome: "failed", detail: "start_failed", version: null };
|
|
110
|
+
}
|
|
111
|
+
let now = status();
|
|
112
|
+
const confirmed = await waitFor(() => {
|
|
113
|
+
now = status();
|
|
114
|
+
return writerIsRunning(now) && now.writer?.version === target;
|
|
115
|
+
});
|
|
116
|
+
if (!confirmed)
|
|
117
|
+
return { outcome: "failed", detail: "restart_unconfirmed", version: now.writer?.version ?? null };
|
|
118
|
+
rmSync(pendingRestartPathFor(ctx.home), { force: true });
|
|
119
|
+
return { outcome: "updated", detail: "restarted", version: target };
|
|
120
|
+
}
|