@catalyst-cloud/cli 0.16.30 → 0.16.32

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.
@@ -0,0 +1,186 @@
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 { DEFAULT_WINDOW_TIMES, isTimeZone, normalizeWindowTimes } from "./update-windows.js";
14
+ import { canonicalProject, effectivePolicy, isUpdateComponent, readUpdatePolicy, setComponentPolicy, setProjectSdkPolicy, setWindowPolicy, UPDATE_COMPONENTS, } from "./update-policy.js";
15
+ 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> | update policy windows <HH:MM,...>|default [--time-zone <zone>]";
16
+ /** How each component reads in a sentence. */
17
+ const SENTENCE_NAMES = {
18
+ runner: "runner",
19
+ supervisor: "supervisor",
20
+ watchdog: "watchdog",
21
+ cli: "CLI",
22
+ "skills-cloud": "Cloud skills",
23
+ "skills-dev": "Dev skills",
24
+ replica: "local replica",
25
+ };
26
+ function absoluteFlag(args, name) {
27
+ const value = flagString(args, name);
28
+ if (value !== undefined && !isAbsolute(value))
29
+ throw new UsageError(`--${name} must be an absolute path`);
30
+ return value;
31
+ }
32
+ function cmdPolicy(parts, args, ctx) {
33
+ const project = absoluteFlag(args, "project");
34
+ if (flagBool(args, "check") || args.flags.dir !== undefined)
35
+ throw new UsageError("--check and --dir are not valid with update policy");
36
+ if (parts.length === 1) {
37
+ if (project !== undefined)
38
+ throw new UsageError("update policy lists every setting; --project goes with update policy sdk on|off");
39
+ const policy = readEffectivePolicy(ctx);
40
+ if (args.json)
41
+ ctx.stdout(JSON.stringify({
42
+ components: UPDATE_COMPONENTS.map((id) => ({ id, ...effectivePolicy(policy, id) })),
43
+ projects: policy.projects.map((p) => ({ path: p.path, sdk: p.sdk, source: p.source })),
44
+ windows: { times: policy.windows?.times ?? [...DEFAULT_WINDOW_TIMES], timeZone: policy.windows?.timeZone ?? null, source: policy.windows ? "owner" : "default" },
45
+ }));
46
+ else
47
+ for (const line of policyLines(policy))
48
+ ctx.stdout(line);
49
+ return 0;
50
+ }
51
+ if (parts[1] === "windows")
52
+ return cmdWindows(parts, args, ctx);
53
+ if (args.flags["time-zone"] !== undefined)
54
+ throw new UsageError("--time-zone goes with update policy windows");
55
+ const [, name, value] = parts;
56
+ if (parts.length !== 3 || (value !== "on" && value !== "off") || name === undefined)
57
+ throw new UsageError(USAGE);
58
+ const enabled = value === "on";
59
+ if (name === "sdk") {
60
+ if (project === undefined)
61
+ throw new UsageError("update policy sdk needs --project <absolute path>");
62
+ const saved = setProjectSdkPolicy(ctx.home, project, enabled ? "auto" : "notify");
63
+ const text = enabled
64
+ ? `Automatic SDK updates are on for ${saved.path}. Release checks may update its package files.`
65
+ : `Automatic SDK updates are off for ${saved.path}. Release checks will only report available updates.`;
66
+ if (args.json)
67
+ ctx.stdout(JSON.stringify({ project: saved.path, sdk: saved.sdk, source: saved.source }));
68
+ else
69
+ ctx.stdout(text);
70
+ return 0;
71
+ }
72
+ if (project !== undefined)
73
+ throw new UsageError("--project goes with update policy sdk on|off");
74
+ if (!isUpdateComponent(name))
75
+ throw new UsageError(`unknown component: ${name}. Use one of ${UPDATE_COMPONENTS.join(", ")} or sdk`);
76
+ setComponentPolicy(ctx.home, name, enabled);
77
+ if (args.json)
78
+ ctx.stdout(JSON.stringify({ id: name, enabled, source: "owner" }));
79
+ else
80
+ ctx.stdout(`Automatic ${SENTENCE_NAMES[name]} updates are ${value}. Other components keep their settings.`);
81
+ return 0;
82
+ }
83
+ /** `update policy windows 03:00,11:00|default [--time-zone <zone>]`: when the schedule may take a
84
+ * newer runner release. `default` goes back to the four default times in the machine's zone. */
85
+ function cmdWindows(parts, args, ctx) {
86
+ const value = parts[2];
87
+ if (parts.length !== 3 || value === undefined)
88
+ throw new UsageError(USAGE);
89
+ const zone = flagString(args, "time-zone");
90
+ if (value === "default") {
91
+ if (zone !== undefined)
92
+ throw new UsageError("update policy windows default uses this machine's time zone; leave out --time-zone");
93
+ setWindowPolicy(ctx.home, null);
94
+ }
95
+ else {
96
+ const times = normalizeWindowTimes(value.split(","));
97
+ if (times === null)
98
+ throw new UsageError("update windows are 24-hour times separated by commas, for example 03:00,11:00,16:00,22:00");
99
+ if (zone !== undefined && !isTimeZone(zone))
100
+ throw new UsageError(`unknown time zone: ${zone}. Use a name such as America/Chicago`);
101
+ setWindowPolicy(ctx.home, { times, timeZone: zone ?? null });
102
+ }
103
+ const saved = readUpdatePolicy(ctx.home).windows;
104
+ const times = saved?.times ?? [...DEFAULT_WINDOW_TIMES];
105
+ const timeZone = saved?.timeZone ?? null;
106
+ if (args.json)
107
+ ctx.stdout(JSON.stringify({ windows: { times, timeZone, source: saved ? "owner" : "default" } }));
108
+ else
109
+ ctx.stdout(`Runner releases now apply at ${times.join(", ")} (${timeZone ?? "this machine's time zone"}). catalyst runner update still applies one at once.`);
110
+ return 0;
111
+ }
112
+ async function cmdSdk(parts, args, ctx, deps) {
113
+ if (parts.length !== 1)
114
+ throw new UsageError(USAGE);
115
+ if (flagBool(args, "check"))
116
+ throw new UsageError("update sdk updates the project; to only look, run catalyst update --check --project <path>");
117
+ const flag = absoluteFlag(args, "project");
118
+ if (flag === undefined)
119
+ throw new UsageError("update sdk needs --project <absolute path>");
120
+ const project = canonicalProject(flag).path;
121
+ const dirFlag = absoluteFlag(args, "dir");
122
+ const report = await runUpdate(ctx, {
123
+ mode: "check",
124
+ imagesDir: dirFlag ?? runnerDir(ctx),
125
+ project,
126
+ engine: () => deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }),
127
+ });
128
+ if (report.approved === null || report.crossTrain !== null) {
129
+ const lines = report.crossTrain
130
+ ? [`The approved release is on train ${report.crossTrain.approved}; SDK updates stay on train ${report.crossTrain.supported}.`, "Nothing was changed."]
131
+ : updateReportLines(report).filter((line) => line.startsWith("Approved release unavailable") || line === "Nothing was changed.");
132
+ if (args.json)
133
+ ctx.stdout(JSON.stringify({ project, outcome: "refused", detail: report.crossTrain ? "cross_train" : (report.unavailable?.reason ?? "approval_unavailable"), moved: [] }));
134
+ else
135
+ for (const line of lines)
136
+ ctx.stdout(line);
137
+ return report.crossTrain ? 0 : 1;
138
+ }
139
+ const result = applySdkUpdate(ctx.home, project, report.approved, deps.sdkExec);
140
+ const registered = readUpdatePolicy(ctx.home).projects.find((p) => p.path === project);
141
+ if (args.json) {
142
+ ctx.stdout(JSON.stringify({ project, outcome: result.outcome, detail: result.detail, moved: result.moved }));
143
+ }
144
+ else {
145
+ const manager = result.project.packageManager ?? "its package manager";
146
+ if (result.outcome === "updated")
147
+ for (const move of result.moved)
148
+ ctx.stdout(`${move.name} in ${project}: updated from ${move.from} to ${move.to} with ${manager}.`);
149
+ else if (result.outcome === "current")
150
+ ctx.stdout(`SDK ${project}: already on the approved version.`);
151
+ else if (result.outcome === "refused")
152
+ ctx.stdout(`SDK ${project}: not changed (${result.detail}).`);
153
+ else
154
+ 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.`);
155
+ ctx.stdout(`Automatic SDK updates remain ${registered?.sdk === "auto" ? "on" : "off"} for ${project}.`);
156
+ }
157
+ return result.outcome === "updated" || result.outcome === "current" ? 0 : 1;
158
+ }
159
+ export async function cmdUpdate(args, ctx, deps = {}) {
160
+ const parts = positionals(args);
161
+ if (parts[0] === "policy")
162
+ return cmdPolicy(parts, args, ctx);
163
+ if (parts[0] === "sdk")
164
+ return cmdSdk(parts, args, ctx, deps);
165
+ if (parts.length > 0)
166
+ throw new UsageError(USAGE);
167
+ const dirFlag = absoluteFlag(args, "dir");
168
+ const project = absoluteFlag(args, "project");
169
+ const report = await runUpdate(ctx, {
170
+ mode: flagBool(args, "check") ? "check" : "apply",
171
+ imagesDir: dirFlag ?? runnerDir(ctx),
172
+ // Read the project where it really is; an inspection never registers it.
173
+ ...(project !== undefined ? { project: canonicalProject(project).path } : {}),
174
+ engine: () => deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }),
175
+ ...(deps.sdkExec ? { sdkExec: deps.sdkExec } : {}),
176
+ ...(deps.cliApply ? { cliApply: deps.cliApply } : {}),
177
+ ...(deps.replicaRestart ? { replicaRestart: deps.replicaRestart } : {}),
178
+ ...(deps.packApply ? { packApply: deps.packApply } : {}),
179
+ });
180
+ if (args.json)
181
+ ctx.stdout(JSON.stringify(updateReportJson(report)));
182
+ else
183
+ for (const line of updateReportLines(report))
184
+ ctx.stdout(line);
185
+ return report.exitCode;
186
+ }
@@ -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,213 @@
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
+ import { isTimeZone, normalizeWindowTimes } from "./update-windows.js";
19
+ export const UPDATE_POLICY_FILE = "updates.json";
20
+ const LOCK_STALE_MS = 30_000;
21
+ const LOCK_WAIT_MS = 5_000;
22
+ /** The installed components a release check keeps current. The SDK is per project, not here. */
23
+ export const UPDATE_COMPONENTS = ["runner", "supervisor", "watchdog", "cli", "skills-cloud", "skills-dev", "replica"];
24
+ export function updatePolicyPathFor(home) {
25
+ return join(configDirFor(home), UPDATE_POLICY_FILE);
26
+ }
27
+ const emptyPolicy = () => ({ version: 1, components: {}, projects: [] });
28
+ export function isUpdateComponent(value) {
29
+ return UPDATE_COMPONENTS.includes(value);
30
+ }
31
+ /** Read the saved preferences. A missing file is the default (everything on). A file that is not a
32
+ * valid record is an error, never silently "everything on": that would undo an owner's hold. */
33
+ export function readUpdatePolicy(home) {
34
+ const path = updatePolicyPathFor(home);
35
+ if (!existsSync(path))
36
+ return emptyPolicy();
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(readFileSync(path, "utf8"));
40
+ }
41
+ catch {
42
+ throw new CliError(`update settings at ${path} are not valid JSON; fix or remove the file`, "update-policy-corrupt", 1);
43
+ }
44
+ const policy = parsePolicy(parsed);
45
+ if (!policy)
46
+ 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);
47
+ return policy;
48
+ }
49
+ function parsePolicy(input) {
50
+ if (!isRecord(input) || input.version !== 1)
51
+ return null;
52
+ const policy = emptyPolicy();
53
+ if (input.components !== undefined) {
54
+ if (!isRecord(input.components))
55
+ return null;
56
+ for (const [name, value] of Object.entries(input.components)) {
57
+ if (!isUpdateComponent(name))
58
+ return null;
59
+ if (!isRecord(value) || typeof value.enabled !== "boolean")
60
+ return null;
61
+ if (value.source !== "owner" && value.source !== "migrated")
62
+ return null;
63
+ policy.components[name] = { enabled: value.enabled, source: value.source };
64
+ }
65
+ }
66
+ if (input.projects !== undefined) {
67
+ if (!Array.isArray(input.projects))
68
+ return null;
69
+ for (const value of input.projects) {
70
+ if (!isRecord(value) || typeof value.path !== "string" || !isAbsolute(value.path))
71
+ return null;
72
+ if (value.sdk !== "notify" && value.sdk !== "auto")
73
+ return null;
74
+ if (value.source !== "owner" && value.source !== "migrated")
75
+ return null;
76
+ if (typeof value.dev !== "number" || typeof value.ino !== "number")
77
+ return null;
78
+ policy.projects.push({ path: value.path, sdk: value.sdk, source: value.source, dev: value.dev, ino: value.ino });
79
+ }
80
+ }
81
+ if (input.windows !== undefined) {
82
+ if (!isRecord(input.windows) || !Array.isArray(input.windows.times))
83
+ return null;
84
+ const times = input.windows.times.every((t) => typeof t === "string") ? normalizeWindowTimes(input.windows.times) : null;
85
+ const zone = input.windows.timeZone;
86
+ if (times === null || !(zone === null || (typeof zone === "string" && isTimeZone(zone))))
87
+ return null;
88
+ policy.windows = { times, timeZone: zone };
89
+ }
90
+ return policy;
91
+ }
92
+ /** The components an installer's daily update kept current: the CLI and both skills packs. */
93
+ export const DAILY_UPDATE_COMPONENTS = ["cli", "skills-cloud", "skills-dev"];
94
+ /**
95
+ * An owner who turned the installer's daily update off chose not to have the CLI and the skills
96
+ * packs moved for them. Until they save a setting of their own for one of those, it reads as held,
97
+ * with source `migrated`. A saved setting always wins, and nothing is written, so a check stays
98
+ * read-only and a later `update policy … on` lifts it. A missing record is no evidence: no hold.
99
+ */
100
+ export function withMigratedHolds(policy, dailyUpdateOff) {
101
+ if (!dailyUpdateOff)
102
+ return policy;
103
+ const components = { ...policy.components };
104
+ for (const id of DAILY_UPDATE_COMPONENTS)
105
+ components[id] ??= { enabled: false, source: "migrated" };
106
+ return { ...policy, components };
107
+ }
108
+ export function effectivePolicy(policy, component) {
109
+ const saved = policy.components[component];
110
+ return saved ? { enabled: saved.enabled, source: saved.source } : { enabled: true, source: "default" };
111
+ }
112
+ /** A project path as the policy stores it: absolute, real, and a directory. */
113
+ export function canonicalProject(path) {
114
+ if (!isAbsolute(path))
115
+ throw new UsageError("--project must be an absolute path");
116
+ let real;
117
+ try {
118
+ real = realpathSync(path);
119
+ }
120
+ catch {
121
+ throw new CliError(`no project at ${path}`, "project-missing", 1);
122
+ }
123
+ const stat = statSync(real);
124
+ if (!stat.isDirectory())
125
+ throw new CliError(`${path} is not a directory`, "project-missing", 1);
126
+ return { path: real, dev: stat.dev, ino: stat.ino };
127
+ }
128
+ /** Whether a registered project is still the directory its owner registered. */
129
+ export function projectIdentityHolds(project) {
130
+ try {
131
+ if (realpathSync(project.path) !== project.path)
132
+ return false;
133
+ const stat = statSync(project.path);
134
+ return stat.isDirectory() && stat.dev === project.dev && stat.ino === project.ino;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ function takePolicyLock(lockPath, nowMs) {
141
+ const deadline = nowMs() + LOCK_WAIT_MS;
142
+ for (;;) {
143
+ try {
144
+ closeSync(openSync(lockPath, "wx", 0o600));
145
+ return;
146
+ }
147
+ catch (err) {
148
+ if (!(err instanceof Error && "code" in err && err.code === "EEXIST"))
149
+ throw err;
150
+ }
151
+ try {
152
+ if (nowMs() - statSync(lockPath).mtimeMs > LOCK_STALE_MS)
153
+ unlinkSync(lockPath);
154
+ }
155
+ catch {
156
+ // gone already: try again
157
+ }
158
+ if (nowMs() > deadline)
159
+ throw new CliError("another command is changing the update settings; try again", "update-policy-busy", 1);
160
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
161
+ }
162
+ }
163
+ /** Change the saved preferences under the lock: read the current file, apply `change`, replace it. */
164
+ export function updateUpdatePolicy(home, change, nowMs = Date.now) {
165
+ const dir = configDirFor(home);
166
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
167
+ const path = updatePolicyPathFor(home);
168
+ const lockPath = `${path}.lock`;
169
+ takePolicyLock(lockPath, nowMs);
170
+ try {
171
+ const policy = readUpdatePolicy(home);
172
+ change(policy);
173
+ writePrivateAtomic(path, `${JSON.stringify(policy, null, 2)}\n`);
174
+ return policy;
175
+ }
176
+ finally {
177
+ try {
178
+ unlinkSync(lockPath);
179
+ }
180
+ catch {
181
+ // already released
182
+ }
183
+ }
184
+ }
185
+ export function setComponentPolicy(home, component, enabled) {
186
+ return updateUpdatePolicy(home, (policy) => {
187
+ policy.components[component] = { enabled, source: "owner" };
188
+ });
189
+ }
190
+ /** Register a project (or change its SDK setting). The stored path is the real path. */
191
+ export function setProjectSdkPolicy(home, path, sdk) {
192
+ const project = canonicalProject(path);
193
+ let saved = null;
194
+ updateUpdatePolicy(home, (policy) => {
195
+ const entry = { ...project, sdk, source: "owner" };
196
+ const at = policy.projects.findIndex((p) => p.path === project.path);
197
+ if (at === -1)
198
+ policy.projects.push(entry);
199
+ else
200
+ policy.projects[at] = entry;
201
+ saved = entry;
202
+ });
203
+ return saved ?? { ...project, sdk, source: "owner" };
204
+ }
205
+ /** Save the owner's update windows, or go back to the defaults with null. */
206
+ export function setWindowPolicy(home, windows) {
207
+ return updateUpdatePolicy(home, (policy) => {
208
+ if (windows === null)
209
+ delete policy.windows;
210
+ else
211
+ policy.windows = windows;
212
+ });
213
+ }