@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.
@@ -0,0 +1,262 @@
1
+ // update-sdk.ts — a project's Catalyst SDK against the approved release (CTC-6708). The SDK is a
2
+ // dependency in the customer's own project, pinned by their lockfile, so a release check only
3
+ // reads it: the declared range, the lockfile's resolution and the installed package are read
4
+ // separately (a manifest range is never reported as the installed version), and the owner is given
5
+ // the one command that moves the project to the approved versions. Reading writes nothing.
6
+ //
7
+ // The project's files change only through `applySdkUpdate`: when its owner runs
8
+ // `catalyst update sdk --project <path>`, or has turned automatic SDK updates on for that project.
9
+ // It runs the project's own package manager by argv (never a shell, never a full upgrade) with
10
+ // exact versions in the dependency group each package already sits in, holds a per-project lock,
11
+ // keeps the package files' bytes, puts them back if the install fails, and reads the result back
12
+ // before calling it updated.
13
+ //
14
+ // Only train members the project already declares are named. Each uses its own approved version:
15
+ // the SDK and its replica modules may sit on different patches of one train.
16
+ import { spawnSync } from "node:child_process";
17
+ import { createHash } from "node:crypto";
18
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { configDirFor } from "./config.js";
21
+ import { compareVersions, exactVersion } from "./approved-release.js";
22
+ import { isRecord } from "./runner-update.js";
23
+ export const SDK_PACKAGES = {
24
+ "@catalyst-cloud/sdk": "sdk",
25
+ "@catalyst-cloud/sdk-replica-node": "sdk-replica-node",
26
+ "@catalyst-cloud/sdk-replica-browser": "sdk-replica-browser",
27
+ };
28
+ const LOCKFILES = {
29
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
30
+ pnpm: ["pnpm-lock.yaml"],
31
+ bun: ["bun.lock", "bun.lockb"],
32
+ yarn: ["yarn.lock"],
33
+ };
34
+ const GROUPS = ["dependencies", "devDependencies", "optionalDependencies"];
35
+ function readJson(path) {
36
+ try {
37
+ return JSON.parse(readFileSync(path, "utf8"));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** The manager the project declares, else the single one whose lockfile is present. Two lockfiles
44
+ * and no declaration is ambiguous: the report says so instead of picking the first one found. */
45
+ export function detectPackageManager(dir, manifest) {
46
+ const present = Object.keys(LOCKFILES).filter((m) => LOCKFILES[m].some((f) => existsSync(join(dir, f))));
47
+ if (typeof manifest.packageManager === "string") {
48
+ const name = manifest.packageManager.split("@")[0];
49
+ if (name === "npm" || name === "pnpm" || name === "bun" || name === "yarn") {
50
+ if (present.length > 0 && !present.includes(name))
51
+ return { manager: null, reason: "lockfile_mismatch" };
52
+ return { manager: name, reason: null };
53
+ }
54
+ return { manager: null, reason: "package_manager_unsupported" };
55
+ }
56
+ if (present.length === 1)
57
+ return { manager: present[0] ?? null, reason: null };
58
+ if (present.length > 1)
59
+ return { manager: null, reason: "package_manager_ambiguous" };
60
+ return { manager: "npm", reason: null };
61
+ }
62
+ function npmLocked(dir, name) {
63
+ for (const file of LOCKFILES.npm) {
64
+ const lock = readJson(join(dir, file));
65
+ if (!isRecord(lock) || !isRecord(lock.packages))
66
+ continue;
67
+ const entry = lock.packages[`node_modules/${name}`];
68
+ if (isRecord(entry))
69
+ return exactVersion(entry.version);
70
+ }
71
+ return null;
72
+ }
73
+ function installedVersion(dir, name) {
74
+ const manifest = readJson(join(dir, "node_modules", ...name.split("/"), "package.json"));
75
+ return isRecord(manifest) ? exactVersion(manifest.version) : null;
76
+ }
77
+ const quote = (value) => (/^[A-Za-z0-9_./@:=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
78
+ /** What runs is what node_modules holds; else what the lockfile resolves; else an exact declaration. */
79
+ const currentVersion = (p) => p.installed ?? p.locked ?? exactVersion(p.declared);
80
+ /** A package already past its approved version: an update keeps it where it is, never moves it down. */
81
+ const aheadOfApproved = (p) => {
82
+ const current = currentVersion(p);
83
+ return current !== null && p.approved !== null && compareVersions(current, p.approved) > 0;
84
+ };
85
+ /** The exact-version add invocations for the declared packages, one per dependency group, so each
86
+ * package stays in the group it is already in. Every spec is a known package at an exact version.
87
+ * A package already newer than its approved version is left out. */
88
+ export function sdkUpdateArgv(manager, packages) {
89
+ const byGroup = new Map();
90
+ for (const p of packages)
91
+ if (p.approved !== null && !aheadOfApproved(p))
92
+ byGroup.set(p.group, [...(byGroup.get(p.group) ?? []), `${p.name}@${p.approved}`]);
93
+ return [...byGroup].map(([group, specs]) => {
94
+ const flags = {
95
+ npm: ["install", "--save-exact", ...(group === "devDependencies" ? ["--save-dev"] : group === "optionalDependencies" ? ["--save-optional"] : [])],
96
+ pnpm: ["add", "--save-exact", ...(group === "devDependencies" ? ["--save-dev"] : group === "optionalDependencies" ? ["--save-optional"] : [])],
97
+ bun: ["add", "--exact", ...(group === "devDependencies" ? ["--dev"] : group === "optionalDependencies" ? ["--optional"] : [])],
98
+ yarn: ["add", "--exact", ...(group === "devDependencies" ? ["--dev"] : group === "optionalDependencies" ? ["--optional"] : [])],
99
+ };
100
+ return [manager, ...flags[manager], ...specs];
101
+ });
102
+ }
103
+ /** The same move as one command a person can paste. */
104
+ export function sdkUpdateCommand(dir, manager, packages) {
105
+ const argv = sdkUpdateArgv(manager, packages);
106
+ if (argv.length === 0)
107
+ return null;
108
+ return `cd ${quote(dir)} && ${argv.map((a) => a.map(quote).join(" ")).join(" && ")}`;
109
+ }
110
+ /** What `catalyst update sdk` tells the owner to run: this CLI's own explicit, one-project update. */
111
+ export function catalystSdkCommand(dir) {
112
+ return `catalyst update sdk --project ${quote(dir)}`;
113
+ }
114
+ /** Read one project's SDK packages against `release` (null when no approved record is usable). */
115
+ export function inspectSdkProject(dir, release) {
116
+ const base = { path: dir, packageManager: null, packages: [], command: null, packageManagerCommand: null };
117
+ const manifest = readJson(join(dir, "package.json"));
118
+ if (!isRecord(manifest))
119
+ return { ...base, status: "unknown", reason: "package_json_unreadable" };
120
+ const packages = [];
121
+ for (const [name, member] of Object.entries(SDK_PACKAGES)) {
122
+ for (const group of GROUPS) {
123
+ const deps = manifest[group];
124
+ if (!isRecord(deps) || typeof deps[name] !== "string")
125
+ continue;
126
+ packages.push({
127
+ name,
128
+ group,
129
+ declared: deps[name],
130
+ locked: null,
131
+ installed: installedVersion(dir, name),
132
+ approved: release?.members[member] ?? null,
133
+ });
134
+ break;
135
+ }
136
+ }
137
+ if (packages.length === 0)
138
+ return { ...base, status: "no_sdk", reason: "no_sdk" };
139
+ const detected = detectPackageManager(dir, manifest);
140
+ if (detected.manager === "npm")
141
+ for (const p of packages)
142
+ p.locked = npmLocked(dir, p.name);
143
+ const project = { ...base, packageManager: detected.manager, packages };
144
+ if (release === null)
145
+ return { ...project, status: "approval_unavailable", reason: "approval_unavailable" };
146
+ if (detected.manager === null)
147
+ return { ...project, status: "unknown", reason: detected.reason ?? "package_manager_ambiguous" };
148
+ const pairs = [];
149
+ for (const p of packages) {
150
+ if (p.approved === null)
151
+ return { ...project, status: "approval_unavailable", reason: "approval_unavailable" };
152
+ const current = currentVersion(p);
153
+ if (current === null)
154
+ return { ...project, status: "unknown", reason: "installed_version_unknown" };
155
+ pairs.push({ current, approved: p.approved });
156
+ }
157
+ if (pairs.some((v) => compareVersions(v.current, v.approved) < 0))
158
+ return {
159
+ ...project,
160
+ status: "update_available",
161
+ reason: "behind_approved",
162
+ command: catalystSdkCommand(dir),
163
+ packageManagerCommand: sdkUpdateCommand(dir, detected.manager, packages),
164
+ };
165
+ if (pairs.some((v) => compareVersions(v.current, v.approved) > 0))
166
+ return { ...project, status: "installed_newer", reason: "installed_newer" };
167
+ return { ...project, status: "current", reason: "current" };
168
+ }
169
+ export const spawnPackageManager = (argv, cwd) => {
170
+ const [cmd, ...args] = argv;
171
+ if (cmd === undefined)
172
+ return { status: 1, stderr: "no command" };
173
+ const run = spawnSync(cmd, args, { cwd, stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 10 * 60_000 });
174
+ return { status: run.error ? 1 : run.status, stderr: run.error ? run.error.message : (run.stderr ?? "") };
175
+ };
176
+ /** The package files a manager writes: the manifest and every lockfile name it may create. */
177
+ const PACKAGE_FILES = ["package.json", ...Object.values(LOCKFILES).flat()];
178
+ /** Longer than the package manager's own timeout, so a live run's lock is never taken over. */
179
+ const SDK_LOCK_STALE_MS = 15 * 60_000;
180
+ function takeProjectLock(home, dir) {
181
+ const locks = join(configDirFor(home), "sdk-update-locks");
182
+ mkdirSync(locks, { recursive: true, mode: 0o700 });
183
+ const path = join(locks, `${createHash("sha256").update(dir).digest("hex").slice(0, 32)}.lock`);
184
+ for (let attempt = 0; attempt < 2; attempt++) {
185
+ try {
186
+ closeSync(openSync(path, "wx", 0o600));
187
+ return path;
188
+ }
189
+ catch {
190
+ // A run killed mid-install leaves its lock behind; past the stale age it is taken over.
191
+ try {
192
+ if (Date.now() - statSync(path).mtimeMs <= SDK_LOCK_STALE_MS)
193
+ return null;
194
+ unlinkSync(path);
195
+ }
196
+ catch {
197
+ // gone already: try again
198
+ }
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+ /** Move one project's declared SDK packages to their approved versions with its own manager. */
204
+ export function applySdkUpdate(home, dir, release, exec = spawnPackageManager) {
205
+ const project = inspectSdkProject(dir, release);
206
+ if (project.status === "current")
207
+ return { outcome: "current", detail: "current", moved: [], project };
208
+ if (project.status !== "update_available" || project.packageManager === null)
209
+ return { outcome: "refused", detail: project.reason, moved: [], project };
210
+ const manager = project.packageManager;
211
+ const lock = takeProjectLock(home, dir);
212
+ if (lock === null)
213
+ return { outcome: "refused", detail: "update_running", moved: [], project };
214
+ try {
215
+ // Each package file's bytes before the run, or null for one that did not exist yet.
216
+ const kept = new Map(PACKAGE_FILES.map((f) => [f, existsSync(join(dir, f)) ? readFileSync(join(dir, f)) : null]));
217
+ const failed = (detail) => {
218
+ // Put back what this run changed: earlier bytes for a file it rewrote, removal for one it created.
219
+ for (const [file, bytes] of kept) {
220
+ const path = join(dir, file);
221
+ const now = existsSync(path) ? readFileSync(path) : null;
222
+ if (bytes === null) {
223
+ if (now !== null)
224
+ rmSync(path, { force: true });
225
+ }
226
+ else if (now === null || !now.equals(bytes))
227
+ writeFileSync(path, bytes);
228
+ }
229
+ return { outcome: "failed", detail, moved: [], project: inspectSdkProject(dir, release) };
230
+ };
231
+ for (const argv of sdkUpdateArgv(manager, project.packages)) {
232
+ const run = exec(argv, dir);
233
+ if (run.status !== 0)
234
+ return failed("package_manager_failed");
235
+ }
236
+ const after = inspectSdkProject(dir, release);
237
+ const moved = [];
238
+ for (const p of after.packages) {
239
+ const before = project.packages.find((q) => q.name === p.name);
240
+ const now = p.installed ?? p.locked;
241
+ if (before !== undefined && aheadOfApproved(before)) {
242
+ // Left out of the install: it must still be where it was.
243
+ const stays = currentVersion(p);
244
+ if (stays === null || compareVersions(stays, currentVersion(before) ?? stays) !== 0)
245
+ return failed("verify_failed");
246
+ continue;
247
+ }
248
+ if (now === null || p.approved === null || compareVersions(now, p.approved) !== 0)
249
+ return failed("verify_failed");
250
+ moved.push({ name: p.name, from: before?.installed ?? before?.locked ?? before?.declared ?? "unknown", to: now });
251
+ }
252
+ return { outcome: "updated", detail: manager, moved, project: after };
253
+ }
254
+ finally {
255
+ try {
256
+ unlinkSync(lock);
257
+ }
258
+ catch {
259
+ // released already
260
+ }
261
+ }
262
+ }
@@ -0,0 +1,64 @@
1
+ // update-skills.ts — the Cloud skills pack moves to its approved version (CTC-6708). The pack
2
+ // ships inside the CLI package of the same version, so a release check installs that exact CLI
3
+ // package into its own directory (update-cli.ts's stageCliRuntime, which checks it and selects
4
+ // nothing), checks that its bundled skills carry the approved version's stamp, and refreshes the
5
+ // copies of those skills this install already made. That works while the CLI itself is held: the
6
+ // pack comes from the staged package, never from the older CLI's own bundle.
7
+ //
8
+ // It changes only what installSkills already treats as its own: a skill folder stamped by this
9
+ // package. A person's own skill of the same name, a symlinked entry (a plugin or the installer's
10
+ // link) and any skill not installed yet are left as they are. Success is read back from the stamps
11
+ // on disk.
12
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { installedBundleVersion, installSkills } from "./skills.js";
15
+ import { stageCliRuntime, stagedSkillsDir } from "./update-cli.js";
16
+ const skillNames = (dir) => readdirSync(dir, { withFileTypes: true })
17
+ .filter((e) => e.isDirectory() && existsSync(join(dir, e.name, "SKILL.md")))
18
+ .map((e) => e.name);
19
+ /** Stamped Cloud skills that are links: installSkills never writes through one, so copying cannot move them. */
20
+ function linkedPackEntries(skillsDir) {
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(skillsDir);
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ return entries.filter((name) => {
29
+ try {
30
+ return lstatSync(join(skillsDir, name)).isSymbolicLink() && installedBundleVersion(skillsDir, [name]).version !== null;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ });
36
+ }
37
+ /** Refresh the Cloud pack's installed skills in `skillsDir` to exact version `target`. */
38
+ export function applyCloudPackUpdate(home, skillsDir, target, deps = {}) {
39
+ // Checked before any download, so a linked pack is not fetched again on every run.
40
+ if (linkedPackEntries(skillsDir).length > 0)
41
+ return { outcome: "linked", detail: "installation_owner_unknown", version: null };
42
+ const staged = stageCliRuntime(home, target, deps);
43
+ if (!staged.ok)
44
+ return { outcome: "failed", detail: staged.detail, version: null };
45
+ const source = stagedSkillsDir(staged.root);
46
+ let names;
47
+ try {
48
+ names = skillNames(source);
49
+ }
50
+ catch {
51
+ return { outcome: "failed", detail: "pack_mismatch", version: null };
52
+ }
53
+ // The staged package must carry exactly the approved pack, or nothing is copied.
54
+ if (names.length === 0 || installedBundleVersion(source, names).version !== target)
55
+ return { outcome: "failed", detail: "pack_mismatch", version: null };
56
+ try {
57
+ installSkills(skillsDir, { onlyExisting: true }, source, target);
58
+ }
59
+ catch {
60
+ return { outcome: "failed", detail: "install_failed", version: installedBundleVersion(skillsDir, names).version };
61
+ }
62
+ const now = installedBundleVersion(skillsDir, names).version;
63
+ return now === target ? { outcome: "updated", detail: "refreshed", version: now } : { outcome: "failed", detail: "verify_failed", version: now };
64
+ }