@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.
- 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 +7 -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 +95 -11
- package/dist/runner-update.js +93 -19
- package/dist/update-cli.js +234 -0
- package/dist/update-command.js +186 -0
- package/dist/update-inventory.js +96 -0
- package/dist/update-plugin.js +99 -0
- package/dist/update-policy.js +213 -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-windows.js +81 -0
- package/dist/update.js +589 -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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// update-windows.ts — the set times of day a self-hosted machine takes a new release (CTC-6709).
|
|
2
|
+
// The 15-minute schedule checks often so an owner sees the approved release quickly, but a busy
|
|
3
|
+
// machine restarting its supervisor every time a release lands would interrupt its owner's day as
|
|
4
|
+
// often as releases ship. So the schedule applies a newer release only once a window has opened
|
|
5
|
+
// since the last window a run settled, and an owner's own `catalyst runner update` applies at once.
|
|
6
|
+
// Windows are wall-clock times in the machine's time zone, or the zone its owner saved.
|
|
7
|
+
/** Four windows a day, spread so a release approved at any time waits at most about eight hours. */
|
|
8
|
+
export const DEFAULT_WINDOW_TIMES = ["03:00", "11:00", "16:00", "22:00"];
|
|
9
|
+
const TIME = /^([01][0-9]|2[0-3]):([0-5][0-9])$/;
|
|
10
|
+
export function defaultWindows() {
|
|
11
|
+
return { times: [...DEFAULT_WINDOW_TIMES], timeZone: null };
|
|
12
|
+
}
|
|
13
|
+
export function isWindowTime(value) {
|
|
14
|
+
return TIME.test(value);
|
|
15
|
+
}
|
|
16
|
+
/** Whether this runtime knows the zone. `Intl` throws a RangeError for one it does not. */
|
|
17
|
+
export function isTimeZone(value) {
|
|
18
|
+
try {
|
|
19
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
20
|
+
return value.length > 0;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function machineTimeZone() {
|
|
27
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
28
|
+
}
|
|
29
|
+
/** Sorted, distinct `HH:MM` times, or null when any entry is not one or there are none. */
|
|
30
|
+
export function normalizeWindowTimes(values) {
|
|
31
|
+
if (values.length === 0 || values.length > 24 || !values.every(isWindowTime))
|
|
32
|
+
return null;
|
|
33
|
+
return [...new Set(values)].sort();
|
|
34
|
+
}
|
|
35
|
+
/** The zone's wall clock at an instant, as the parts a window time is compared with. */
|
|
36
|
+
function wallClock(ms, timeZone) {
|
|
37
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
38
|
+
timeZone,
|
|
39
|
+
hourCycle: "h23",
|
|
40
|
+
year: "numeric",
|
|
41
|
+
month: "2-digit",
|
|
42
|
+
day: "2-digit",
|
|
43
|
+
hour: "2-digit",
|
|
44
|
+
minute: "2-digit",
|
|
45
|
+
}).formatToParts(new Date(ms));
|
|
46
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
47
|
+
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
|
|
48
|
+
}
|
|
49
|
+
/** The zone's offset from UTC at an instant, in milliseconds, to the minute. */
|
|
50
|
+
function offsetMs(ms, timeZone) {
|
|
51
|
+
const w = wallClock(ms, timeZone);
|
|
52
|
+
return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute) - Math.floor(ms / 60_000) * 60_000;
|
|
53
|
+
}
|
|
54
|
+
/** The instant a wall-clock time falls on in the zone. A time a clock change skips or repeats lands within an hour of it. */
|
|
55
|
+
function instantOf(year, month, day, hour, minute, timeZone) {
|
|
56
|
+
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
57
|
+
const first = asUtc - offsetMs(asUtc, timeZone);
|
|
58
|
+
return asUtc - offsetMs(first, timeZone);
|
|
59
|
+
}
|
|
60
|
+
/** The window before and after `nowMs`. */
|
|
61
|
+
export function windowBounds(nowMs, windows) {
|
|
62
|
+
const timeZone = windows.timeZone ?? machineTimeZone();
|
|
63
|
+
const times = normalizeWindowTimes(windows.times) ?? [...DEFAULT_WINDOW_TIMES];
|
|
64
|
+
const today = wallClock(nowMs, timeZone);
|
|
65
|
+
const candidates = [];
|
|
66
|
+
// Two calendar days either side of today's date in the zone. Stepping by date, not by 24 hours,
|
|
67
|
+
// keeps a 23- or 25-hour day around a clock change from skipping a date or reading one twice.
|
|
68
|
+
for (let shift = -2; shift <= 2; shift++) {
|
|
69
|
+
const date = new Date(Date.UTC(today.year, today.month - 1, today.day + shift));
|
|
70
|
+
for (const time of times) {
|
|
71
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
72
|
+
candidates.push(instantOf(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), hour, minute, timeZone));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
candidates.sort((a, b) => a - b);
|
|
76
|
+
const lastOpenedMs = candidates.filter((ms) => ms <= nowMs).pop();
|
|
77
|
+
const nextOpensMs = candidates.find((ms) => ms > nowMs);
|
|
78
|
+
if (lastOpenedMs === undefined || nextOpensMs === undefined)
|
|
79
|
+
throw new Error(`no update window around ${new Date(nowMs).toISOString()} in ${timeZone}`);
|
|
80
|
+
return { lastOpenedMs, nextOpensMs, timeZone };
|
|
81
|
+
}
|