@bygghammar/cms-platform 0.92.2 → 0.94.0
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 +93 -0
- package/package.json +2 -1
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -0
- package/src/backoffice/VersionView.tsx +747 -66
- package/src/backoffice/media/ReuploadOverwrite.tsx +2 -2
- package/src/backoffice/media/ReviewImageUpload.tsx +8 -4
- package/src/backoffice/routes/index.tsx +94 -9
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- package/src/config/modules.ts +11 -0
- package/src/media/FocalImage.tsx +12 -16
- package/src/media/optimize.ts +8 -6
- package/src/media/review-image.ts +22 -8
- package/src/media/srcset.ts +13 -6
- package/src/popups/package-name.ts +8 -0
- package/src/server/media-library-core.ts +5 -3
- package/src/server/review-image-validate.ts +3 -2
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build metadata and
|
|
2
|
+
* Build metadata, release identity and package update checks for the version
|
|
3
|
+
* screen — the server half.
|
|
3
4
|
*
|
|
4
5
|
* The build constants are injected by the site's Vite config (they must be, the
|
|
5
|
-
* values only exist at build time); reading them defensively
|
|
6
|
-
*
|
|
6
|
+
* values only exist at build time); reading them defensively, reading the
|
|
7
|
+
* release identity from the runtime, and comparing installed first-party
|
|
8
|
+
* packages with the public registry is identical everywhere, so it lives here.
|
|
9
|
+
* The data contract and update policy are in `./version-report`.
|
|
7
10
|
*/
|
|
11
|
+
import { CMS_MODULE_LIST } from "../config/modules";
|
|
12
|
+
import { createRegistryClient, type RegistryClient } from "./registry-metadata";
|
|
13
|
+
import { isPrerelease, parseSemver } from "./semver";
|
|
14
|
+
import {
|
|
15
|
+
evaluatePackageUpdate,
|
|
16
|
+
type PackageReport,
|
|
17
|
+
type ReleaseHistoryEntry,
|
|
18
|
+
type ReleaseIdentity,
|
|
19
|
+
type VersionReport,
|
|
20
|
+
} from "./version-report";
|
|
8
21
|
|
|
9
22
|
export type BuildInfo = {
|
|
10
23
|
appVersion: string;
|
|
@@ -25,12 +38,22 @@ export type BuildConstants = {
|
|
|
25
38
|
buildTime?: unknown;
|
|
26
39
|
platformVersion?: unknown;
|
|
27
40
|
platformRange?: unknown;
|
|
41
|
+
/**
|
|
42
|
+
* Installed first-party packages, read by the host build from its installed
|
|
43
|
+
* dependency tree: `[{ name, installed, declared, requiredByCms }]`.
|
|
44
|
+
*/
|
|
45
|
+
packages?: unknown;
|
|
28
46
|
};
|
|
29
47
|
|
|
30
48
|
const text = (value: unknown, fallback: string): string =>
|
|
31
49
|
typeof value === "string" && value ? value : fallback;
|
|
32
50
|
|
|
33
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Normalize the injected build constants into a complete `BuildInfo`.
|
|
53
|
+
*
|
|
54
|
+
* Kept for hosts that still read the original shape. Its fallbacks fill gaps
|
|
55
|
+
* with placeholders, so the version screen itself reads `buildVersionReport`.
|
|
56
|
+
*/
|
|
34
57
|
export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
|
|
35
58
|
return {
|
|
36
59
|
appVersion: text(constants.appVersion, "0.0.0"),
|
|
@@ -43,34 +66,219 @@ export function readBuildInfoFrom(constants: BuildConstants): BuildInfo {
|
|
|
43
66
|
/** The npm package name of the shared CMS platform. */
|
|
44
67
|
export const PLATFORM_PACKAGE = "@bygghammar/cms-platform";
|
|
45
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Every package the server may look up in the public registry: the CMS
|
|
71
|
+
* package and the first-party packages modules declare. Fixed in code; a
|
|
72
|
+
* request can never add to it.
|
|
73
|
+
*/
|
|
74
|
+
export const FIRST_PARTY_PACKAGES: readonly string[] = [
|
|
75
|
+
PLATFORM_PACKAGE,
|
|
76
|
+
...new Set(CMS_MODULE_LIST.flatMap((m) => (m.package ? [m.package] : []))),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
let sharedClient: RegistryClient | null = null;
|
|
80
|
+
|
|
81
|
+
/** The process-wide registry client, created on first use. */
|
|
82
|
+
export function platformRegistryClient(): RegistryClient {
|
|
83
|
+
sharedClient ??= createRegistryClient({ allowlist: FIRST_PARTY_PACKAGES });
|
|
84
|
+
return sharedClient;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Env = Record<string, string | undefined>;
|
|
88
|
+
|
|
89
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
90
|
+
const RELEASE_LABEL = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The deployed release, from the same runtime variables the health endpoint
|
|
94
|
+
* reports (`APP_VERSION`, then `VITE_PUBLIC_APP_VERSION`). The site's
|
|
95
|
+
* `package.json` version is not a release identity and is never used here.
|
|
96
|
+
*/
|
|
97
|
+
export function readReleaseIdentity(env: Env = process.env): ReleaseIdentity {
|
|
98
|
+
for (const source of ["APP_VERSION", "VITE_PUBLIC_APP_VERSION"]) {
|
|
99
|
+
const raw = (env[source] ?? "").trim();
|
|
100
|
+
if (!raw || raw.toLowerCase() === "unknown") continue;
|
|
101
|
+
if (COMMIT_SHA.test(raw)) return { value: raw, kind: "commit", source, unrecognized: false };
|
|
102
|
+
if (RELEASE_LABEL.test(raw)) return { value: raw, kind: "label", source, unrecognized: false };
|
|
103
|
+
return { value: null, kind: null, source, unrecognized: true };
|
|
104
|
+
}
|
|
105
|
+
return { value: null, kind: null, source: null, unrecognized: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The environment name (`SITE_ENV`) and public hostname (`SITE_URL`), each only
|
|
110
|
+
* when it has a plain, expected form. Nothing else from the environment is read.
|
|
111
|
+
*/
|
|
112
|
+
export function readEnvironment(env: Env = process.env): VersionReport["environment"] {
|
|
113
|
+
const rawName = (env["SITE_ENV"] ?? "").trim().toLowerCase();
|
|
114
|
+
const name = /^[a-z][a-z0-9_-]{0,31}$/.test(rawName) ? rawName : null;
|
|
115
|
+
let origin: string | null = null;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL((env["SITE_URL"] ?? "").trim());
|
|
118
|
+
if (url.protocol === "https:" || url.protocol === "http:") origin = url.hostname || null;
|
|
119
|
+
} catch {
|
|
120
|
+
origin = null;
|
|
121
|
+
}
|
|
122
|
+
return { name, origin };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stringOrNull(value: unknown, max = 128): string | null {
|
|
126
|
+
return typeof value === "string" && value.trim() && value.length <= max ? value.trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isoOrNull(value: unknown): string | null {
|
|
130
|
+
if (typeof value !== "string" || !value) return null;
|
|
131
|
+
const ms = Date.parse(value);
|
|
132
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type InstalledEntry = {
|
|
136
|
+
installed: string | null;
|
|
137
|
+
declared: string | null;
|
|
138
|
+
requiredByCms: string | null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The injected package list, keyed by name, allowlisted packages only. */
|
|
142
|
+
export function readInstalledPackages(constants: BuildConstants): Map<string, InstalledEntry> {
|
|
143
|
+
const out = new Map<string, InstalledEntry>();
|
|
144
|
+
if (Array.isArray(constants.packages)) {
|
|
145
|
+
for (const item of constants.packages as unknown[]) {
|
|
146
|
+
if (typeof item !== "object" || item === null) continue;
|
|
147
|
+
const rec = item as Record<string, unknown>;
|
|
148
|
+
const name = rec["name"];
|
|
149
|
+
if (typeof name !== "string" || !FIRST_PARTY_PACKAGES.includes(name)) continue;
|
|
150
|
+
out.set(name, {
|
|
151
|
+
installed: stringOrNull(rec["installed"]),
|
|
152
|
+
declared: stringOrNull(rec["declared"]),
|
|
153
|
+
requiredByCms: stringOrNull(rec["requiredByCms"]),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Hosts built before the package list existed still inject the CMS version.
|
|
158
|
+
if (!out.has(PLATFORM_PACKAGE)) {
|
|
159
|
+
const version = stringOrNull(constants.platformVersion);
|
|
160
|
+
out.set(PLATFORM_PACKAGE, {
|
|
161
|
+
installed: version === "unknown" ? null : version,
|
|
162
|
+
declared: stringOrNull(constants.platformRange) === "unknown" ? null : stringOrNull(constants.platformRange),
|
|
163
|
+
requiredByCms: null,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type VersionReportOptions = {
|
|
170
|
+
constants: BuildConstants;
|
|
171
|
+
/** Runtime environment; defaults to `process.env`. */
|
|
172
|
+
env?: Env;
|
|
173
|
+
/** Ask the registry again now, subject to the client's minimum interval. */
|
|
174
|
+
refresh?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* When this release went live — only from real evidence the host holds.
|
|
177
|
+
* Omit when nothing records it; the screen then says it was not recorded.
|
|
178
|
+
*/
|
|
179
|
+
deployedAt?: string | null;
|
|
180
|
+
/** How many recent CMS publications to list. Default 5. */
|
|
181
|
+
historyLimit?: number;
|
|
182
|
+
registry?: RegistryClient;
|
|
183
|
+
now?: () => Date;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Assemble the version screen's report. Never throws for registry trouble. */
|
|
187
|
+
export async function buildVersionReport(options: VersionReportOptions): Promise<VersionReport> {
|
|
188
|
+
const registry = options.registry ?? platformRegistryClient();
|
|
189
|
+
const now = options.now ?? (() => new Date());
|
|
190
|
+
const installed = readInstalledPackages(options.constants);
|
|
191
|
+
|
|
192
|
+
const rows: { name: string; role: PackageReport["role"] }[] = [
|
|
193
|
+
{ name: PLATFORM_PACKAGE, role: "cms" },
|
|
194
|
+
...FIRST_PARTY_PACKAGES.filter((n) => n !== PLATFORM_PACKAGE).map((name) => ({
|
|
195
|
+
name,
|
|
196
|
+
role: "module" as const,
|
|
197
|
+
})),
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const lookups = await Promise.all(
|
|
201
|
+
rows.map((row) => registry.lookup(row.name, { refresh: options.refresh === true })),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const packages: PackageReport[] = rows.map((row, i) => {
|
|
205
|
+
const entry = installed.get(row.name);
|
|
206
|
+
const lookup = lookups[i]!;
|
|
207
|
+
return {
|
|
208
|
+
name: row.name,
|
|
209
|
+
role: row.role,
|
|
210
|
+
installed: entry?.installed ?? null,
|
|
211
|
+
declared: entry?.declared ?? null,
|
|
212
|
+
requiredByCms: row.role === "module" ? (entry?.requiredByCms ?? null) : null,
|
|
213
|
+
registry: lookup.check,
|
|
214
|
+
update: evaluatePackageUpdate(entry?.installed ?? null, lookup.metadata),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const cms = packages[0]!;
|
|
219
|
+
const cmsMetadata = lookups[0]!.metadata;
|
|
220
|
+
const cmsInstalled = parseSemver(cms.installed);
|
|
221
|
+
const releaseHistory: ReleaseHistoryEntry[] = cmsMetadata
|
|
222
|
+
? Object.entries(cmsMetadata.versions)
|
|
223
|
+
.filter(([, v]) => v.publishedAt !== null)
|
|
224
|
+
.map(([version, v]) => {
|
|
225
|
+
const sv = parseSemver(version);
|
|
226
|
+
return {
|
|
227
|
+
version,
|
|
228
|
+
publishedAt: v.publishedAt,
|
|
229
|
+
prerelease: sv ? isPrerelease(sv) : false,
|
|
230
|
+
current: cmsInstalled !== null && cmsInstalled.version === version,
|
|
231
|
+
};
|
|
232
|
+
})
|
|
233
|
+
.sort((a, b) => ((a.publishedAt ?? "") < (b.publishedAt ?? "") ? 1 : -1))
|
|
234
|
+
.slice(0, options.historyLimit ?? 5)
|
|
235
|
+
: [];
|
|
236
|
+
|
|
237
|
+
const nextAttempts = rows
|
|
238
|
+
.map((row) => registry.nextAttemptAt(row.name))
|
|
239
|
+
.filter((t): t is number => t !== null);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: 1,
|
|
243
|
+
generatedAt: now().toISOString(),
|
|
244
|
+
release: readReleaseIdentity(options.env),
|
|
245
|
+
environment: readEnvironment(options.env),
|
|
246
|
+
builtAt: isoOrNull(options.constants.buildTime),
|
|
247
|
+
deployedAt: isoOrNull(options.deployedAt),
|
|
248
|
+
packages,
|
|
249
|
+
releaseHistory,
|
|
250
|
+
nextCheckAllowedAt: nextAttempts.length ? new Date(Math.max(...nextAttempts)).toISOString() : null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The legacy release list, derived from a report's history (newest publication first). */
|
|
255
|
+
export function releasesFromReport(report: VersionReport, limit = 3): PlatformRelease[] {
|
|
256
|
+
return report.releaseHistory.slice(0, limit).map((r) => ({
|
|
257
|
+
version: r.version,
|
|
258
|
+
publishedAt: r.publishedAt,
|
|
259
|
+
current: r.current,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
|
|
46
263
|
/**
|
|
47
264
|
* The most recent published platform releases, newest first.
|
|
48
265
|
*
|
|
49
266
|
* Network failures are not an error worth surfacing on an information screen —
|
|
50
|
-
* the screen still shows the running version from the build constants.
|
|
267
|
+
* the screen still shows the running version from the build constants. Reads
|
|
268
|
+
* through the shared bounded, cached registry client.
|
|
51
269
|
*/
|
|
52
270
|
export async function fetchPlatformReleases(
|
|
53
271
|
currentVersion: string,
|
|
54
272
|
limit = 3,
|
|
55
273
|
): Promise<PlatformRelease[]> {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.map(([version, publishedAt]) => ({
|
|
67
|
-
version,
|
|
68
|
-
publishedAt,
|
|
69
|
-
current: version === currentVersion,
|
|
70
|
-
}))
|
|
71
|
-
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
|
72
|
-
.slice(0, limit);
|
|
73
|
-
} catch {
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
274
|
+
const { metadata } = await platformRegistryClient().lookup(PLATFORM_PACKAGE);
|
|
275
|
+
if (!metadata) return [];
|
|
276
|
+
return Object.entries(metadata.versions)
|
|
277
|
+
.map(([version, v]) => ({
|
|
278
|
+
version,
|
|
279
|
+
publishedAt: v.publishedAt,
|
|
280
|
+
current: version === currentVersion,
|
|
281
|
+
}))
|
|
282
|
+
.sort((a, b) => ((a.publishedAt ?? "") < (b.publishedAt ?? "") ? 1 : -1))
|
|
283
|
+
.slice(0, limit);
|
|
76
284
|
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version screen's data contract, and the update policy it is judged by.
|
|
3
|
+
*
|
|
4
|
+
* Neutral on purpose: the server builds a `VersionReport` (see `version-core`)
|
|
5
|
+
* and the backoffice renders it, so the types and the pure decisions live here,
|
|
6
|
+
* in a module both sides may import. Nothing in this file reads the network,
|
|
7
|
+
* the environment or the database.
|
|
8
|
+
*
|
|
9
|
+
* Update policy, stated once:
|
|
10
|
+
*
|
|
11
|
+
* - Versions are ordered by semver precedence, never as strings and never by
|
|
12
|
+
* publication date.
|
|
13
|
+
* - A release install (`0.93.0`) follows the **stable** channel: the newest
|
|
14
|
+
* published, non-deprecated release version. Prereleases never count as
|
|
15
|
+
* updates for it, whatever their dist-tag.
|
|
16
|
+
* - A prerelease install (`0.2.0-m7.9`) follows its **prerelease line**: the
|
|
17
|
+
* same `major.minor.patch` and first prerelease identifier (`0.2.0-m7`).
|
|
18
|
+
* Being the newest of that line is reported as "prerelease installed", not
|
|
19
|
+
* "up to date". A newer stable release, if one exists, is reported beside it
|
|
20
|
+
* as advisory information, never as the line's update.
|
|
21
|
+
* - An installed version the registry does not list is "not published" or
|
|
22
|
+
* "ahead of registry"; an unknown or unparseable installed version, or no
|
|
23
|
+
* registry answer at all, is never called up to date.
|
|
24
|
+
*/
|
|
25
|
+
import { compareSemver, isPrerelease, parseSemver, prereleaseLine, type SemVer } from "./semver";
|
|
26
|
+
|
|
27
|
+
/** One published version, as the registry describes it. */
|
|
28
|
+
export type RegistryVersion = {
|
|
29
|
+
readonly publishedAt: string | null;
|
|
30
|
+
readonly deprecated: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The subset of registry metadata the version screen uses. */
|
|
34
|
+
export type RegistryMetadata = {
|
|
35
|
+
readonly distTags: Readonly<Record<string, string>>;
|
|
36
|
+
/** Keyed by normalized version (`major.minor.patch[-prerelease]`). */
|
|
37
|
+
readonly versions: Readonly<Record<string, RegistryVersion>>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Fixed, non-sensitive reasons a registry lookup did not produce metadata. */
|
|
41
|
+
export type RegistryError =
|
|
42
|
+
| "timeout"
|
|
43
|
+
| "network"
|
|
44
|
+
| "http"
|
|
45
|
+
| "not-found"
|
|
46
|
+
| "malformed"
|
|
47
|
+
| "too-large"
|
|
48
|
+
| "not-allowed";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `fresh`: the most recent attempt succeeded, within the cache lifetime.
|
|
52
|
+
* `stale`: an older successful answer is being shown, because the most recent
|
|
53
|
+
* attempt failed (see `error`) or the answer outlived the cache lifetime; it is
|
|
54
|
+
* cached, not re-verified. `unavailable`: no successful answer is held.
|
|
55
|
+
*/
|
|
56
|
+
export type RegistryCheckState = "fresh" | "stale" | "unavailable";
|
|
57
|
+
|
|
58
|
+
export type RegistryCheck = {
|
|
59
|
+
readonly state: RegistryCheckState;
|
|
60
|
+
/** When the registry last answered with usable metadata (ISO 8601). */
|
|
61
|
+
readonly lastSuccessAt: string | null;
|
|
62
|
+
/** When the most recent lookup was attempted, successful or not (ISO 8601). */
|
|
63
|
+
readonly lastAttemptAt: string | null;
|
|
64
|
+
/** Why the most recent attempt failed, if it did. */
|
|
65
|
+
readonly error: RegistryError | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type UpdateStatus =
|
|
69
|
+
| "up-to-date"
|
|
70
|
+
| "update-available"
|
|
71
|
+
| "prerelease-current"
|
|
72
|
+
| "check-unavailable"
|
|
73
|
+
| "not-published"
|
|
74
|
+
| "ahead-of-registry"
|
|
75
|
+
| "installed-unknown"
|
|
76
|
+
| "installed-invalid";
|
|
77
|
+
|
|
78
|
+
export type UpdateChannel = { kind: "stable" } | { kind: "prerelease"; line: string };
|
|
79
|
+
|
|
80
|
+
export type PublishedVersion = { version: string; publishedAt: string | null };
|
|
81
|
+
|
|
82
|
+
export type PackageUpdate = {
|
|
83
|
+
readonly status: UpdateStatus;
|
|
84
|
+
readonly channel: UpdateChannel | null;
|
|
85
|
+
/** Publication date of the installed version, when the registry lists it. */
|
|
86
|
+
readonly installedPublishedAt: string | null;
|
|
87
|
+
readonly installedDeprecated: boolean;
|
|
88
|
+
/** Newest published, non-deprecated version in the installed version's channel. */
|
|
89
|
+
readonly latestInChannel: PublishedVersion | null;
|
|
90
|
+
/** For a prerelease install: the newest stable release newer than it. Advisory only. */
|
|
91
|
+
readonly newerStable: PublishedVersion | null;
|
|
92
|
+
/** Registry dist-tags that currently point at the installed version. */
|
|
93
|
+
readonly installedTags: readonly string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type PackageRole = "cms" | "module";
|
|
97
|
+
|
|
98
|
+
/** One first-party package as installed in this build, with its registry verdict. */
|
|
99
|
+
export type PackageReport = {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly role: PackageRole;
|
|
102
|
+
/** Exact version in the build's installed dependency tree. */
|
|
103
|
+
readonly installed: string | null;
|
|
104
|
+
/** What the site's own manifest declares for it (a range or exact version). */
|
|
105
|
+
readonly declared: string | null;
|
|
106
|
+
/** The range the installed CMS package declares for it, for module packages. */
|
|
107
|
+
readonly requiredByCms: string | null;
|
|
108
|
+
readonly registry: RegistryCheck;
|
|
109
|
+
readonly update: PackageUpdate;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type ReleaseIdentity = {
|
|
113
|
+
/** The deployed release, e.g. a full commit SHA. `null` when not reported. */
|
|
114
|
+
readonly value: string | null;
|
|
115
|
+
readonly kind: "commit" | "label" | null;
|
|
116
|
+
/** The runtime variable it was read from. */
|
|
117
|
+
readonly source: string | null;
|
|
118
|
+
/** True when a value was present but not in a recognised format (it is not echoed). */
|
|
119
|
+
readonly unrecognized: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type ReleaseHistoryEntry = {
|
|
123
|
+
readonly version: string;
|
|
124
|
+
readonly publishedAt: string | null;
|
|
125
|
+
readonly prerelease: boolean;
|
|
126
|
+
readonly current: boolean;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type VersionReport = {
|
|
130
|
+
readonly schemaVersion: 1;
|
|
131
|
+
/** When the server assembled this report (ISO 8601). */
|
|
132
|
+
readonly generatedAt: string;
|
|
133
|
+
readonly release: ReleaseIdentity;
|
|
134
|
+
readonly environment: { readonly name: string | null; readonly origin: string | null };
|
|
135
|
+
/** When the server bundle was built. Not a deployment time. */
|
|
136
|
+
readonly builtAt: string | null;
|
|
137
|
+
/** When this release went live, only if the host has real evidence of it. */
|
|
138
|
+
readonly deployedAt: string | null;
|
|
139
|
+
/** The CMS package first, then module packages. */
|
|
140
|
+
readonly packages: readonly PackageReport[];
|
|
141
|
+
/** Recent CMS package publications, newest publication first. */
|
|
142
|
+
readonly releaseHistory: readonly ReleaseHistoryEntry[];
|
|
143
|
+
/** The earliest time a manual "check again" will reach the registry (ISO 8601). */
|
|
144
|
+
readonly nextCheckAllowedAt: string | null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type Published = { sv: SemVer; info: RegistryVersion };
|
|
148
|
+
|
|
149
|
+
function maxOf(list: readonly Published[]): Published | null {
|
|
150
|
+
let best: Published | null = null;
|
|
151
|
+
for (const p of list) if (!best || compareSemver(p.sv, best.sv) > 0) best = p;
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function asPublished(p: Published | null): PublishedVersion | null {
|
|
156
|
+
return p ? { version: p.sv.version, publishedAt: p.info.publishedAt } : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inChannel(sv: SemVer, channel: UpdateChannel): boolean {
|
|
160
|
+
return channel.kind === "stable" ? !isPrerelease(sv) : prereleaseLine(sv) === channel.line;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const EMPTY_UPDATE = {
|
|
164
|
+
channel: null,
|
|
165
|
+
installedPublishedAt: null,
|
|
166
|
+
installedDeprecated: false,
|
|
167
|
+
latestInChannel: null,
|
|
168
|
+
newerStable: null,
|
|
169
|
+
installedTags: [],
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
/** Judge one installed version against registry metadata, by the policy above. */
|
|
173
|
+
export function evaluatePackageUpdate(
|
|
174
|
+
installedRaw: string | null | undefined,
|
|
175
|
+
metadata: RegistryMetadata | null | undefined,
|
|
176
|
+
): PackageUpdate {
|
|
177
|
+
if (!installedRaw) return { ...EMPTY_UPDATE, status: "installed-unknown" };
|
|
178
|
+
const installed = parseSemver(installedRaw);
|
|
179
|
+
if (!installed) return { ...EMPTY_UPDATE, status: "installed-invalid" };
|
|
180
|
+
|
|
181
|
+
const line = prereleaseLine(installed);
|
|
182
|
+
const channel: UpdateChannel = line ? { kind: "prerelease", line } : { kind: "stable" };
|
|
183
|
+
if (!metadata) return { ...EMPTY_UPDATE, channel, status: "check-unavailable" };
|
|
184
|
+
|
|
185
|
+
const published: Published[] = [];
|
|
186
|
+
for (const [key, info] of Object.entries(metadata.versions)) {
|
|
187
|
+
const sv = parseSemver(key);
|
|
188
|
+
if (sv) published.push({ sv, info });
|
|
189
|
+
}
|
|
190
|
+
const own = published.find((p) => compareSemver(p.sv, installed) === 0) ?? null;
|
|
191
|
+
const live = published.filter((p) => !p.info.deprecated);
|
|
192
|
+
const latest = maxOf(live.filter((p) => inChannel(p.sv, channel)));
|
|
193
|
+
const installedTags = Object.entries(metadata.distTags)
|
|
194
|
+
.filter(([, v]) => {
|
|
195
|
+
const sv = parseSemver(v);
|
|
196
|
+
return sv !== null && compareSemver(sv, installed) === 0;
|
|
197
|
+
})
|
|
198
|
+
.map(([tag]) => tag)
|
|
199
|
+
.sort();
|
|
200
|
+
const newerStable =
|
|
201
|
+
channel.kind === "prerelease"
|
|
202
|
+
? maxOf(live.filter((p) => !isPrerelease(p.sv) && compareSemver(p.sv, installed) > 0))
|
|
203
|
+
: null;
|
|
204
|
+
|
|
205
|
+
const common = {
|
|
206
|
+
channel,
|
|
207
|
+
installedPublishedAt: own?.info.publishedAt ?? null,
|
|
208
|
+
installedDeprecated: own?.info.deprecated ?? false,
|
|
209
|
+
latestInChannel: asPublished(latest),
|
|
210
|
+
newerStable: asPublished(newerStable),
|
|
211
|
+
installedTags,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (!own) {
|
|
215
|
+
// Not in the registry: a local or unpublished build. It is "ahead" only
|
|
216
|
+
// when it is newer than everything published in its channel (or, with an
|
|
217
|
+
// empty channel, than everything published at all).
|
|
218
|
+
const reference = latest ?? maxOf(published);
|
|
219
|
+
const ahead = reference !== null && compareSemver(installed, reference.sv) > 0;
|
|
220
|
+
return { ...common, status: ahead ? "ahead-of-registry" : "not-published" };
|
|
221
|
+
}
|
|
222
|
+
if (latest && compareSemver(installed, latest.sv) < 0) {
|
|
223
|
+
return { ...common, status: "update-available" };
|
|
224
|
+
}
|
|
225
|
+
return { ...common, status: channel.kind === "stable" ? "up-to-date" : "prerelease-current" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Statuses that should draw an administrator's attention beyond "an update exists". */
|
|
229
|
+
const ATTENTION: ReadonlySet<UpdateStatus> = new Set([
|
|
230
|
+
"not-published",
|
|
231
|
+
"ahead-of-registry",
|
|
232
|
+
"installed-unknown",
|
|
233
|
+
"installed-invalid",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
export type UpdateSummaryKind =
|
|
237
|
+
| "updates-available"
|
|
238
|
+
| "attention"
|
|
239
|
+
| "up-to-date"
|
|
240
|
+
| "partial"
|
|
241
|
+
| "unavailable";
|
|
242
|
+
|
|
243
|
+
export type UpdateSummary = {
|
|
244
|
+
readonly kind: UpdateSummaryKind;
|
|
245
|
+
readonly updates: readonly string[];
|
|
246
|
+
readonly prereleases: readonly string[];
|
|
247
|
+
readonly attention: readonly string[];
|
|
248
|
+
readonly unavailable: readonly string[];
|
|
249
|
+
/** Oldest "last successful check" across the packages, when every one has one. */
|
|
250
|
+
readonly checkedAt: string | null;
|
|
251
|
+
/** True when any package's registry answer is cached rather than current. */
|
|
252
|
+
readonly stale: boolean;
|
|
253
|
+
/** Packages whose most recent registry attempt failed. */
|
|
254
|
+
readonly failed: readonly string[];
|
|
255
|
+
/**
|
|
256
|
+
* True only when every package has a fresh answer from a successful most
|
|
257
|
+
* recent attempt. A verdict built on cached answers is not verified.
|
|
258
|
+
*/
|
|
259
|
+
readonly verified: boolean;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** One overall verdict for the summary banner. */
|
|
263
|
+
export function summarizeUpdates(packages: readonly PackageReport[]): UpdateSummary {
|
|
264
|
+
const updates: string[] = [];
|
|
265
|
+
const prereleases: string[] = [];
|
|
266
|
+
const attention: string[] = [];
|
|
267
|
+
const unavailable: string[] = [];
|
|
268
|
+
for (const p of packages) {
|
|
269
|
+
const s = p.update.status;
|
|
270
|
+
if (s === "update-available") updates.push(p.name);
|
|
271
|
+
if (s === "prerelease-current" || (s === "update-available" && p.update.channel?.kind === "prerelease"))
|
|
272
|
+
prereleases.push(p.name);
|
|
273
|
+
if (ATTENTION.has(s) || p.update.installedDeprecated) attention.push(p.name);
|
|
274
|
+
if (s === "check-unavailable") unavailable.push(p.name);
|
|
275
|
+
}
|
|
276
|
+
const successes = packages.map((p) => p.registry.lastSuccessAt);
|
|
277
|
+
const checkedAt =
|
|
278
|
+
packages.length > 0 && successes.every((t): t is string => typeof t === "string")
|
|
279
|
+
? successes.reduce((a, b) => (a < b ? a : b))
|
|
280
|
+
: null;
|
|
281
|
+
const stale = packages.some((p) => p.registry.state === "stale");
|
|
282
|
+
const failed = packages.filter((p) => p.registry.error !== null).map((p) => p.name);
|
|
283
|
+
const verified =
|
|
284
|
+
packages.length > 0 &&
|
|
285
|
+
packages.every((p) => p.registry.state === "fresh" && p.registry.error === null);
|
|
286
|
+
|
|
287
|
+
let kind: UpdateSummaryKind;
|
|
288
|
+
if (packages.length === 0 || unavailable.length === packages.length) kind = "unavailable";
|
|
289
|
+
else if (updates.length > 0) kind = "updates-available";
|
|
290
|
+
else if (attention.length > 0) kind = "attention";
|
|
291
|
+
else if (unavailable.length > 0) kind = "partial";
|
|
292
|
+
else kind = "up-to-date";
|
|
293
|
+
|
|
294
|
+
return { kind, updates, prereleases, attention, unavailable, checkedAt, stale, failed, verified };
|
|
295
|
+
}
|