@caelo-cms/shared 0.10.48 → 0.10.49

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.
Files changed (42) hide show
  1. package/dist/ai-tools.d.ts +4 -3
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +5 -4
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auto-update.d.ts +124 -0
  6. package/dist/auto-update.d.ts.map +1 -0
  7. package/dist/auto-update.js +228 -0
  8. package/dist/auto-update.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/platform-updates.d.ts +6 -1
  14. package/dist/platform-updates.d.ts.map +1 -1
  15. package/dist/platform-updates.js +17 -2
  16. package/dist/platform-updates.js.map +1 -1
  17. package/dist/release-manifest.d.ts +99 -0
  18. package/dist/release-manifest.d.ts.map +1 -0
  19. package/dist/release-manifest.js +161 -0
  20. package/dist/release-manifest.js.map +1 -0
  21. package/dist/seo.d.ts.map +1 -1
  22. package/dist/seo.js +4 -1
  23. package/dist/seo.js.map +1 -1
  24. package/dist/static-cache-policy.d.ts +7 -4
  25. package/dist/static-cache-policy.d.ts.map +1 -1
  26. package/dist/static-cache-policy.js +7 -4
  27. package/dist/static-cache-policy.js.map +1 -1
  28. package/dist/subagents.d.ts +1 -1
  29. package/dist/version.d.ts +2 -2
  30. package/dist/version.js +1 -1
  31. package/package.json +1 -1
  32. package/src/ai-tools.ts +5 -4
  33. package/src/auto-update.test.ts +321 -0
  34. package/src/auto-update.ts +315 -0
  35. package/src/index.ts +2 -0
  36. package/src/platform-updates.test.ts +5 -0
  37. package/src/platform-updates.ts +24 -1
  38. package/src/release-manifest.ts +212 -0
  39. package/src/seo.ts +4 -1
  40. package/src/static-cache-policy.test.ts +6 -2
  41. package/src/static-cache-policy.ts +7 -4
  42. package/src/version.ts +1 -1
@@ -0,0 +1,321 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ type AutoUpdateInput,
6
+ decideAutoUpdate,
7
+ isPausedAt,
8
+ isSecurityRelease,
9
+ nextWindowOpening,
10
+ type ReleaseFacts,
11
+ } from "./auto-update.js";
12
+ import type { StoredUpdatePolicy } from "./platform-updates.js";
13
+
14
+ const NOW = new Date("2026-10-14T12:00:00Z");
15
+ const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
16
+
17
+ const POLICY: StoredUpdatePolicy = {
18
+ mode: "auto",
19
+ autoSecurity: true,
20
+ autoPatch: true,
21
+ autoMinor: false,
22
+ channel: "stable",
23
+ windowStart: null,
24
+ windowEnd: null,
25
+ windowTimezone: "UTC",
26
+ soakHours: 24,
27
+ securitySoakHours: 2,
28
+ paused: false,
29
+ pausedReason: null,
30
+ pausedAt: null,
31
+ pausedUntil: null,
32
+ pausedByOwner: false,
33
+ updatedAt: "2026-10-01T00:00:00Z",
34
+ };
35
+
36
+ const FACTS: ReleaseFacts = {
37
+ security: false,
38
+ advisories: [],
39
+ contractMigrations: false,
40
+ infraChange: false,
41
+ };
42
+ const SECURITY: ReleaseFacts = {
43
+ ...FACTS,
44
+ security: true,
45
+ advisories: ["GHSA-abcd-efgh-jkmp"],
46
+ };
47
+
48
+ const INSTALLED = "0.10.47";
49
+ const VERSIONS = { patch: "0.10.48", minor: "0.11.0", major: "1.0.0" } as const;
50
+
51
+ function input(over: Partial<AutoUpdateInput> & { version?: string; publishedHoursAgo?: number }) {
52
+ const { version = VERSIONS.patch, publishedHoursAgo = 48, ...rest } = over;
53
+ return {
54
+ policy: POLICY,
55
+ installedVersion: INSTALLED,
56
+ release: { version, publishedAt: hoursAgo(publishedHoursAgo) },
57
+ facts: FACTS,
58
+ upgraderAvailable: true,
59
+ now: NOW,
60
+ ...rest,
61
+ } satisfies AutoUpdateInput;
62
+ }
63
+
64
+ describe("decideAutoUpdate — release type × policy toggles", () => {
65
+ type Kind = keyof typeof VERSIONS;
66
+ const cases: [Kind, boolean, Partial<StoredUpdatePolicy>, string, string | null][] = [
67
+ // [type, security, policy toggles, expected action, expected basis]
68
+ ["patch", false, { autoPatch: true }, "install", "patch"],
69
+ ["patch", false, { autoPatch: false, autoSecurity: true }, "notify", null],
70
+ ["minor", false, { autoMinor: true }, "install", "minor"],
71
+ ["minor", false, { autoMinor: false, autoPatch: true }, "notify", null],
72
+ ["patch", true, { autoSecurity: true, autoPatch: false }, "install", "security"],
73
+ ["minor", true, { autoSecurity: true, autoMinor: false }, "install", "security"],
74
+ ["patch", true, { autoSecurity: false, autoPatch: true }, "install", "patch"],
75
+ ["minor", true, { autoSecurity: false, autoMinor: false }, "notify", null],
76
+ // Major never installs automatically, whatever is switched on.
77
+ ["major", false, { autoPatch: true, autoMinor: true, autoSecurity: true }, "notify", null],
78
+ ["major", true, { autoPatch: true, autoMinor: true, autoSecurity: true }, "notify", null],
79
+ ];
80
+ it.each(cases)("%s (security=%s) %o → %s", (kind, security, toggles, action, basis) => {
81
+ const d = decideAutoUpdate(
82
+ input({
83
+ version: VERSIONS[kind],
84
+ policy: { ...POLICY, ...toggles },
85
+ facts: security ? SECURITY : FACTS,
86
+ }),
87
+ );
88
+ expect(d.action).toBe(action);
89
+ expect("basis" in d ? d.basis : null).toBe(basis);
90
+ });
91
+ });
92
+
93
+ describe("decideAutoUpdate — security flag needs a published advisory", () => {
94
+ it("a manifest saying security without a GHSA is a normal release", () => {
95
+ const facts = { ...FACTS, security: isSecurityRelease({ security: true, advisories: [] }) };
96
+ const d = decideAutoUpdate(
97
+ input({ policy: { ...POLICY, autoPatch: false }, facts, publishedHoursAgo: 3 }),
98
+ );
99
+ expect(d.action).toBe("notify");
100
+ });
101
+ it("isSecurityRelease", () => {
102
+ expect(isSecurityRelease({ security: true, advisories: ["GHSA-abcd-efgh-jkmp"] })).toBe(true);
103
+ expect(isSecurityRelease({ security: true, advisories: [] })).toBe(false);
104
+ expect(isSecurityRelease({ security: false, advisories: ["GHSA-abcd-efgh-jkmp"] })).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("decideAutoUpdate — soak period", () => {
109
+ const cases: [string, ReleaseFacts, Partial<StoredUpdatePolicy>, number, string][] = [
110
+ ["patch inside the 24 h soak", FACTS, {}, 23, "wait"],
111
+ ["patch after the 24 h soak", FACTS, {}, 25, "install"],
112
+ ["security inside its 2 h soak", SECURITY, {}, 1, "wait"],
113
+ ["security after its 2 h soak", SECURITY, {}, 3, "install"],
114
+ ["security with zero soak", SECURITY, { securitySoakHours: 0 }, 0, "install"],
115
+ // Security soak only applies when the release installs AS a security fix.
116
+ [
117
+ "security installed as patch keeps the patch soak",
118
+ SECURITY,
119
+ { autoSecurity: false },
120
+ 3,
121
+ "wait",
122
+ ],
123
+ ["zero soak installs at once", FACTS, { soakHours: 0, securitySoakHours: 0 }, 0, "install"],
124
+ ];
125
+ it.each(cases)("%s", (_name, facts, policy, publishedHoursAgo, action) => {
126
+ const d = decideAutoUpdate(
127
+ input({ facts, policy: { ...POLICY, ...policy }, publishedHoursAgo }),
128
+ );
129
+ expect(d.action).toBe(action);
130
+ });
131
+
132
+ it("names when the wait ends", () => {
133
+ const d = decideAutoUpdate(input({ publishedHoursAgo: 20 }));
134
+ expect(d.action).toBe("wait");
135
+ if (d.action !== "wait") return;
136
+ expect(d.until?.toISOString()).toBe(new Date(NOW.getTime() + 4 * 3_600_000).toISOString());
137
+ expect(d.paused).toBe(false);
138
+ });
139
+
140
+ it("a release without a publish date is blocked, not guessed", () => {
141
+ const d = decideAutoUpdate({
142
+ ...input({}),
143
+ release: { version: VERSIONS.patch, publishedAt: null },
144
+ });
145
+ expect(d.action).toBe("blocked");
146
+ expect(d.reason).toContain("publish date");
147
+ });
148
+ });
149
+
150
+ describe("decideAutoUpdate — maintenance window", () => {
151
+ // NOW is 12:00 UTC = 14:00 in Berlin (CEST).
152
+ const cases: [string, Partial<StoredUpdatePolicy>, string][] = [
153
+ [
154
+ "inside a daytime window",
155
+ { windowStart: "13:00", windowEnd: "15:00", windowTimezone: "Europe/Berlin" },
156
+ "install",
157
+ ],
158
+ [
159
+ "outside a night window",
160
+ { windowStart: "02:00", windowEnd: "05:00", windowTimezone: "Europe/Berlin" },
161
+ "wait",
162
+ ],
163
+ [
164
+ "inside a window spanning midnight (UTC)",
165
+ { windowStart: "11:00", windowEnd: "01:00" },
166
+ "install",
167
+ ],
168
+ ["no window = any time", {}, "install"],
169
+ ];
170
+ it.each(cases)("%s", (_name, policy, action) => {
171
+ expect(decideAutoUpdate(input({ policy: { ...POLICY, ...policy } })).action).toBe(action);
172
+ });
173
+
174
+ it("waits until the window opens", () => {
175
+ const d = decideAutoUpdate(
176
+ input({
177
+ policy: {
178
+ ...POLICY,
179
+ windowStart: "02:00",
180
+ windowEnd: "05:00",
181
+ windowTimezone: "Europe/Berlin",
182
+ },
183
+ }),
184
+ );
185
+ expect(d.action === "wait" && d.until?.toISOString()).toBe("2026-10-15T00:00:00.000Z");
186
+ });
187
+
188
+ it("soak ending outside the window waits for the next opening", () => {
189
+ const d = decideAutoUpdate(
190
+ input({
191
+ publishedHoursAgo: 23, // soak ends 13:00 UTC
192
+ policy: { ...POLICY, windowStart: "20:00", windowEnd: "22:00" },
193
+ }),
194
+ );
195
+ expect(d.action === "wait" && d.until?.toISOString()).toBe("2026-10-14T20:00:00.000Z");
196
+ });
197
+ });
198
+
199
+ describe("decideAutoUpdate — pause", () => {
200
+ const cases: [string, Partial<StoredUpdatePolicy>, string, boolean][] = [
201
+ [
202
+ "paused indefinitely by the Owner",
203
+ { paused: true, pausedAt: hoursAgo(1), pausedByOwner: true },
204
+ "wait",
205
+ true,
206
+ ],
207
+ [
208
+ "paused after a failure",
209
+ { paused: true, pausedAt: hoursAgo(1), pausedReason: "rolled back" },
210
+ "wait",
211
+ true,
212
+ ],
213
+ [
214
+ "paused until tomorrow",
215
+ {
216
+ paused: true,
217
+ pausedAt: hoursAgo(1),
218
+ pausedByOwner: true,
219
+ pausedUntil: "2026-10-15T00:00:00Z",
220
+ },
221
+ "wait",
222
+ true,
223
+ ],
224
+ [
225
+ "pause already over",
226
+ { paused: true, pausedAt: hoursAgo(30), pausedByOwner: true, pausedUntil: hoursAgo(1) },
227
+ "install",
228
+ false,
229
+ ],
230
+ ];
231
+ it.each(cases)("%s", (_name, policy, action, paused) => {
232
+ const d = decideAutoUpdate(input({ policy: { ...POLICY, ...policy } }));
233
+ expect(d.action).toBe(action);
234
+ if (d.action === "wait") expect(d.paused).toBe(paused);
235
+ });
236
+
237
+ it("isPausedAt", () => {
238
+ expect(isPausedAt({ paused: false, pausedUntil: null }, NOW)).toBe(false);
239
+ expect(isPausedAt({ paused: true, pausedUntil: null }, NOW)).toBe(true);
240
+ expect(isPausedAt({ paused: true, pausedUntil: hoursAgo(-1) }, NOW)).toBe(true);
241
+ expect(isPausedAt({ paused: true, pausedUntil: hoursAgo(1) }, NOW)).toBe(false);
242
+ });
243
+ });
244
+
245
+ describe("decideAutoUpdate — releases that never install automatically", () => {
246
+ const cases: [string, Partial<AutoUpdateInput>, string][] = [
247
+ ["infra change", { facts: { ...FACTS, infraChange: true } }, "needs_cloud_shell"],
248
+ [
249
+ "infra change on a security fix",
250
+ { facts: { ...SECURITY, infraChange: true } },
251
+ "needs_cloud_shell",
252
+ ],
253
+ ["destructive migrations", { facts: { ...FACTS, contractMigrations: true } }, "manual"],
254
+ ["destructive security fix", { facts: { ...SECURITY, contractMigrations: true } }, "manual"],
255
+ ["manifest unreadable", { facts: { unavailable: "HTTP 404" } }, "blocked"],
256
+ ["no upgrader", { upgraderAvailable: false }, "blocked"],
257
+ ];
258
+ it.each(cases)("%s → %s", (_name, over, action) => {
259
+ expect(decideAutoUpdate({ ...input({}), ...over }).action).toBe(action);
260
+ });
261
+
262
+ it("infra unknown here is left to the upgrader (it checks install.json)", () => {
263
+ expect(decideAutoUpdate(input({ facts: { ...FACTS, infraChange: null } })).action).toBe(
264
+ "install",
265
+ );
266
+ });
267
+
268
+ it("an infra-changing major is still just a major (notify)", () => {
269
+ const d = decideAutoUpdate(
270
+ input({ version: VERSIONS.major, facts: { ...FACTS, infraChange: true } }),
271
+ );
272
+ expect(d.action).toBe("notify");
273
+ });
274
+ });
275
+
276
+ describe("decideAutoUpdate — upgrader not ready", () => {
277
+ it("names why the upgrader cannot take the release (e.g. the self-hosted agent)", () => {
278
+ const d = decideAutoUpdate({
279
+ ...input({}),
280
+ upgraderAvailable: false,
281
+ upgraderProblem: "The update agent on box-1 has not checked in since 10:00.",
282
+ });
283
+ expect(d.action).toBe("blocked");
284
+ expect(d.reason).toContain("has not checked in");
285
+ });
286
+ });
287
+
288
+ describe("decideAutoUpdate — modes and versions", () => {
289
+ it.each([
290
+ ["up to date", { version: INSTALLED }, "none"],
291
+ ["older release", { version: "0.10.40" }, "none"],
292
+ ["notices off", { policy: { ...POLICY, mode: "off" as const } }, "none"],
293
+ ["notify mode", { policy: { ...POLICY, mode: "notify" as const } }, "notify"],
294
+ [
295
+ "notify mode, infra change",
296
+ { policy: { ...POLICY, mode: "notify" as const }, facts: { ...FACTS, infraChange: true } },
297
+ "notify",
298
+ ],
299
+ ["pre-release", { version: "0.11.0-rc.1" }, "notify"],
300
+ ] as const)("%s → %s", (_name, over, action) => {
301
+ expect(decideAutoUpdate(input(over as Parameters<typeof input>[0])).action).toBe(action);
302
+ });
303
+
304
+ it("no release known yet → none", () => {
305
+ expect(decideAutoUpdate({ ...input({}), release: null }).action).toBe("none");
306
+ });
307
+ });
308
+
309
+ describe("nextWindowOpening", () => {
310
+ it("returns the start of the next window in the window's time zone", () => {
311
+ const p = { windowStart: "02:00", windowEnd: "05:00", windowTimezone: "America/New_York" };
312
+ // 12:00 UTC = 08:00 New York (EDT) → next 02:00 New York = 06:00 UTC tomorrow.
313
+ expect(nextWindowOpening(p, NOW)?.toISOString()).toBe("2026-10-15T06:00:00.000Z");
314
+ });
315
+ it("is `from` itself (rounded up to the minute) inside the window", () => {
316
+ const p = { windowStart: "11:00", windowEnd: "13:00", windowTimezone: "UTC" };
317
+ expect(nextWindowOpening(p, new Date("2026-10-14T12:00:30Z"))?.toISOString()).toBe(
318
+ "2026-10-14T12:01:00.000Z",
319
+ );
320
+ });
321
+ });
@@ -0,0 +1,315 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The automatic-update decision (epic #696 Phase 5): given the Owner's
5
+ * policy, the installed and the newest release, and what the release's
6
+ * manifest says about it, what the scheduler inside the admin does now.
7
+ *
8
+ * Pure and table-tested (auto-update.test.ts). The scheduler
9
+ * (admin-core platform-updates/scheduler.ts) gathers the inputs, this
10
+ * decides, the `platform_updates.scheduler_tick` op records the decision,
11
+ * queues the install and the Owner's emails.
12
+ *
13
+ * The scheduler is not the trust boundary. It reads the manifest without
14
+ * checking its signature (the admin image ships no cosign), so it only
15
+ * decides *when to ask*. The upgrader job verifies the signed manifest
16
+ * and refuses an automatic request the policy, the infrastructure contract
17
+ * or destructive migrations forbid (provisioning upgrader-assess.ts),
18
+ * including one queued as a security fix for a release whose signed
19
+ * manifest names no advisory — so a forged "security" flag cannot shorten
20
+ * the soak period.
21
+ *
22
+ * Rules, in order (maintainer decisions on #696):
23
+ * 1. nothing newer, or notices off → none;
24
+ * 2. off the stable channel, or the policy only notifies → notify;
25
+ * 3. major → notify (never automatic before 1.0);
26
+ * 4. release information unreadable → blocked (fails loudly, no guess);
27
+ * 5. changes the cloud setup → needs_cloud_shell (never automatic);
28
+ * 6. destructive database changes → manual (never automatic);
29
+ * 7. the kind is not switched on (security / patch / minor) → notify;
30
+ * 8. no upgrader on this install → blocked;
31
+ * 9. paused by the Owner or after a failure → wait;
32
+ * 10. soak period not over (security releases: their own, shorter one) → wait;
33
+ * 11. outside the maintenance window → wait until it opens;
34
+ * 12. otherwise → install.
35
+ */
36
+
37
+ import {
38
+ inMaintenanceWindow,
39
+ type StoredUpdatePolicy,
40
+ type UpdateKind,
41
+ updateKind,
42
+ } from "./platform-updates.js";
43
+
44
+ /** Why the scheduler queued an automatic install (`platform_update_requests.auto_basis`). */
45
+ export const AUTO_UPDATE_BASES = ["security", "patch", "minor"] as const;
46
+ export type AutoUpdateBasis = (typeof AUTO_UPDATE_BASES)[number];
47
+
48
+ /** What the scheduler last did (`platform_update_scheduler.last_action`). */
49
+ export const AUTO_UPDATE_ACTIONS = [
50
+ "none",
51
+ "notify",
52
+ "wait",
53
+ "install",
54
+ "manual",
55
+ "needs_cloud_shell",
56
+ "blocked",
57
+ ] as const;
58
+ export type AutoUpdateAction = (typeof AUTO_UPDATE_ACTIONS)[number];
59
+
60
+ /** The Owner's update emails (`platform_update_notifications.kind`). */
61
+ export const UPDATE_NOTIFICATION_KINDS = [
62
+ "available",
63
+ "scheduled",
64
+ "installed",
65
+ "failed",
66
+ "needs_cloud_shell",
67
+ ] as const;
68
+ export type UpdateNotificationKind = (typeof UPDATE_NOTIFICATION_KINDS)[number];
69
+
70
+ /**
71
+ * Whether a release counts as a security fix: the signed manifest says so
72
+ * AND names at least one published GitHub Security Advisory (#696 decision
73
+ * 2). The release workflow refuses `security: true` without one; this is
74
+ * the second line.
75
+ */
76
+ export function isSecurityRelease(manifest: {
77
+ readonly security: boolean;
78
+ readonly advisories: readonly string[];
79
+ }): boolean {
80
+ return manifest.security && manifest.advisories.length > 0;
81
+ }
82
+
83
+ /** What the scheduler knows about the newest release from its manifest. */
84
+ export interface ReleaseFacts {
85
+ /** {@link isSecurityRelease} of the manifest. */
86
+ readonly security: boolean;
87
+ readonly advisories: readonly string[];
88
+ /** The release, or one it skips, carries destructive (contract) migrations. */
89
+ readonly contractMigrations: boolean;
90
+ /**
91
+ * The release raises the infrastructure contract over the installed one.
92
+ * null: not known here (the installed release has no manifest, or the
93
+ * install has no separate cloud setup) — the upgrader checks it against
94
+ * the install's own state before it changes anything.
95
+ */
96
+ readonly infraChange: boolean | null;
97
+ }
98
+
99
+ /** Everything {@link decideAutoUpdate} looks at. */
100
+ export interface AutoUpdateInput {
101
+ readonly policy: StoredUpdatePolicy;
102
+ readonly installedVersion: string;
103
+ /** The newest release the release check found, or null. */
104
+ readonly release: { readonly version: string; readonly publishedAt: string | null } | null;
105
+ /** The release's facts, or why they could not be read. */
106
+ readonly facts: ReleaseFacts | { readonly unavailable: string };
107
+ /** This install has an upgrader the admin can hand a request to. */
108
+ readonly upgraderAvailable: boolean;
109
+ /** Why it has none right now, in plain words (null: none set up at all). */
110
+ readonly upgraderProblem?: string | null;
111
+ readonly now: Date;
112
+ }
113
+
114
+ /** What the scheduler does with the newest release now. */
115
+ export type AutoUpdateDecision =
116
+ | { readonly action: "none"; readonly reason: string }
117
+ | {
118
+ readonly action: "notify" | "manual" | "needs_cloud_shell" | "blocked";
119
+ readonly version: string;
120
+ readonly kind: UpdateKind;
121
+ readonly reason: string;
122
+ }
123
+ | {
124
+ readonly action: "wait";
125
+ readonly version: string;
126
+ readonly kind: UpdateKind;
127
+ readonly basis: AutoUpdateBasis;
128
+ readonly reason: string;
129
+ /** When it becomes installable; null = until the Owner resumes. */
130
+ readonly until: Date | null;
131
+ /** Waiting because automatic installs are paused (not soak/window). */
132
+ readonly paused: boolean;
133
+ }
134
+ | {
135
+ readonly action: "install";
136
+ readonly version: string;
137
+ readonly kind: UpdateKind;
138
+ readonly basis: AutoUpdateBasis;
139
+ readonly reason: string;
140
+ };
141
+
142
+ /** Whether automatic installs are paused at `now` (an Owner pause may have ended). */
143
+ export function isPausedAt(
144
+ policy: Pick<StoredUpdatePolicy, "paused" | "pausedUntil">,
145
+ now: Date,
146
+ ): boolean {
147
+ if (!policy.paused) return false;
148
+ return policy.pausedUntil === null || new Date(policy.pausedUntil).getTime() > now.getTime();
149
+ }
150
+
151
+ /**
152
+ * The first minute at or after `from` inside the maintenance window, or
153
+ * null when it does not open within two days (cannot happen for a valid
154
+ * window; null keeps the caller honest instead of guessing).
155
+ */
156
+ export function nextWindowOpening(
157
+ policy: Pick<StoredUpdatePolicy, "windowStart" | "windowEnd" | "windowTimezone">,
158
+ from: Date,
159
+ ): Date | null {
160
+ const start = new Date(Math.ceil(from.getTime() / 60_000) * 60_000);
161
+ for (let m = 0; m <= 2 * 24 * 60; m++) {
162
+ const at = new Date(start.getTime() + m * 60_000);
163
+ if (inMaintenanceWindow(policy, at)) return at;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ const KIND_WORDS: Record<UpdateKind, string> = {
169
+ major: "major release",
170
+ minor: "minor release",
171
+ patch: "patch release",
172
+ };
173
+
174
+ const BASIS_WORDS: Record<AutoUpdateBasis, string> = {
175
+ security: "security fix",
176
+ patch: "patch release",
177
+ minor: "minor release",
178
+ };
179
+
180
+ /** `v0.11.0` channel by its pre-release tag (`0.11.0-rc.1` → rc). */
181
+ function isStable(version: string): boolean {
182
+ return !version.includes("-");
183
+ }
184
+
185
+ const hours = (n: number) => `${n} hour${n === 1 ? "" : "s"}`;
186
+
187
+ /** Decide what the scheduler does now. See the file comment for the rules. */
188
+ export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
189
+ const { policy, release, now } = input;
190
+ if (!release) {
191
+ return { action: "none", reason: "The release check has not found a Caelo release yet." };
192
+ }
193
+ const kind = updateKind(input.installedVersion, release.version);
194
+ if (kind === null) {
195
+ return { action: "none", reason: `v${input.installedVersion} is the newest release.` };
196
+ }
197
+ if (policy.mode === "off") {
198
+ return { action: "none", reason: "Update notices are switched off in the update policy." };
199
+ }
200
+ const v = release.version;
201
+ const base = { version: v, kind } as const;
202
+ if (!isStable(v)) {
203
+ return {
204
+ action: "notify",
205
+ ...base,
206
+ reason: `v${v} is a pre-release; only stable releases install automatically.`,
207
+ };
208
+ }
209
+ if (policy.mode === "notify") {
210
+ return {
211
+ action: "notify",
212
+ ...base,
213
+ reason: `v${v} is available. The update policy only notifies; the Owner installs it.`,
214
+ };
215
+ }
216
+ if (kind === "major") {
217
+ return {
218
+ action: "notify",
219
+ ...base,
220
+ reason: `v${v} is a major release; major releases never install automatically before Caelo 1.0.`,
221
+ };
222
+ }
223
+ if ("unavailable" in input.facts) {
224
+ return {
225
+ action: "blocked",
226
+ ...base,
227
+ reason: `The release information of v${v} could not be read (${input.facts.unavailable}), so it is not installed automatically. The next check tries again; the Owner can install it from Settings → Updates.`,
228
+ };
229
+ }
230
+ const facts = input.facts;
231
+ if (facts.infraChange === true) {
232
+ return {
233
+ action: "needs_cloud_shell",
234
+ ...base,
235
+ reason: `v${v} changes this install's cloud setup, which the admin may not do. It is never installed automatically: the Owner runs it once with their own account in Cloud Shell.`,
236
+ };
237
+ }
238
+ if (facts.contractMigrations) {
239
+ return {
240
+ action: "manual",
241
+ ...base,
242
+ reason: `v${v} makes database changes the previous release cannot run on, so it never installs automatically. The Owner installs it from Settings → Updates.`,
243
+ };
244
+ }
245
+ const basis: AutoUpdateBasis | null =
246
+ facts.security && policy.autoSecurity
247
+ ? "security"
248
+ : kind === "patch" && policy.autoPatch
249
+ ? "patch"
250
+ : kind === "minor" && policy.autoMinor
251
+ ? "minor"
252
+ : null;
253
+ if (basis === null) {
254
+ const what = facts.security ? `security fix (${KIND_WORDS[kind]})` : KIND_WORDS[kind];
255
+ return {
256
+ action: "notify",
257
+ ...base,
258
+ reason: `v${v} is a ${what}; the update policy does not install those automatically. The Owner installs it.`,
259
+ };
260
+ }
261
+ if (!input.upgraderAvailable) {
262
+ return {
263
+ action: "blocked",
264
+ ...base,
265
+ reason: input.upgraderProblem
266
+ ? `v${v} could install automatically, but the upgrader cannot take it now: ${input.upgraderProblem}`
267
+ : `v${v} could install automatically, but this install has no upgrader the admin can start. Run the upgrade command from Settings → Updates once; it sets the upgrader up.`,
268
+ };
269
+ }
270
+ const auto = { ...base, basis } as const;
271
+ if (isPausedAt(policy, now)) {
272
+ const until = policy.pausedUntil === null ? null : new Date(policy.pausedUntil);
273
+ const who = policy.pausedByOwner ? "by the Owner" : "after a failed automatic install";
274
+ return {
275
+ action: "wait",
276
+ ...auto,
277
+ until,
278
+ paused: true,
279
+ reason: `Automatic installs are paused ${who}${until ? ` until ${until.toISOString()}` : " until the Owner resumes them"}${policy.pausedReason ? ` (${policy.pausedReason})` : ""}.`,
280
+ };
281
+ }
282
+ if (release.publishedAt === null) {
283
+ return {
284
+ action: "blocked",
285
+ ...base,
286
+ reason: `The publish date of v${v} is not known, so its soak period cannot be counted. The next release check fills it in.`,
287
+ };
288
+ }
289
+ const soak = basis === "security" ? policy.securitySoakHours : policy.soakHours;
290
+ const ready = new Date(new Date(release.publishedAt).getTime() + soak * 3_600_000);
291
+ if (ready.getTime() > now.getTime()) {
292
+ const opening = inMaintenanceWindow(policy, ready) ? ready : nextWindowOpening(policy, ready);
293
+ return {
294
+ action: "wait",
295
+ ...auto,
296
+ until: opening,
297
+ paused: false,
298
+ reason: `v${v} (${BASIS_WORDS[basis]}) installs automatically once it has been out for ${hours(soak)}${opening && opening.getTime() !== ready.getTime() ? " and the maintenance window is open" : ""}.`,
299
+ };
300
+ }
301
+ if (!inMaintenanceWindow(policy, now)) {
302
+ return {
303
+ action: "wait",
304
+ ...auto,
305
+ until: nextWindowOpening(policy, now),
306
+ paused: false,
307
+ reason: `v${v} (${BASIS_WORDS[basis]}) installs automatically when the maintenance window opens (${policy.windowStart}–${policy.windowEnd} ${policy.windowTimezone}).`,
308
+ };
309
+ }
310
+ return {
311
+ action: "install",
312
+ ...auto,
313
+ reason: `v${v} (${BASIS_WORDS[basis]}) installs automatically: the update policy allows it, its ${hours(soak)} soak period is over${policy.windowStart ? " and the maintenance window is open" : ""}.`,
314
+ };
315
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export const PROJECT_NAME = "caelo-cms";
4
4
 
5
5
  export * from "./ai-tools.js";
6
6
  export * from "./auth-forms.js";
7
+ export * from "./auto-update.js";
7
8
  export * from "./base-css.js";
8
9
  export * from "./build-page.js";
9
10
  export * from "./cap-failures.js";
@@ -43,6 +44,7 @@ export * from "./preview-scanner.js";
43
44
  export * from "./proposal-status.js";
44
45
  export * from "./provisioning-command.js";
45
46
  export * from "./redirect-target.js";
47
+ export * from "./release-manifest.js";
46
48
  export * from "./responsive-images.js";
47
49
  export * from "./result.js";
48
50
  export * from "./safe-keys.js";
@@ -66,9 +66,12 @@ describe("autoInstallRefusal", () => {
66
66
  windowEnd: null,
67
67
  windowTimezone: "UTC",
68
68
  soakHours: 24,
69
+ securitySoakHours: 2,
69
70
  paused: false,
70
71
  pausedReason: null,
71
72
  pausedAt: null,
73
+ pausedUntil: null,
74
+ pausedByOwner: false,
72
75
  updatedAt: "2026-10-13T00:00:00Z",
73
76
  };
74
77
  const now = new Date("2026-10-13T12:00:00Z");
@@ -125,6 +128,7 @@ describe("updatePolicySchema", () => {
125
128
  windowEnd: "04:00",
126
129
  windowTimezone: "Europe/Berlin",
127
130
  soakHours: 24,
131
+ securitySoakHours: 2,
128
132
  };
129
133
  it("accepts a sane policy and rejects broken ones", () => {
130
134
  expect(updatePolicySchema.safeParse(base).success).toBe(true);
@@ -134,6 +138,7 @@ describe("updatePolicySchema", () => {
134
138
  { ...base, windowStart: "25:00" },
135
139
  { ...base, windowTimezone: "Nowhere/Land" },
136
140
  { ...base, soakHours: 1000 },
141
+ { ...base, securitySoakHours: 48 },
137
142
  { ...base, extra: true },
138
143
  ]) {
139
144
  expect(updatePolicySchema.safeParse(bad).success).toBe(false);