newspaper_wedding/.aidlc/tools/aidlc-update.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

294 lines
9.4 KiB
TypeScript

import { existsSync, readFileSync, rmSync } from "node:fs";
import { relative } from "node:path";
import { AIDLC_VERSION } from "./aidlc-version.ts";
import {
compareVersions,
isReleaseChannel,
PREVIEW_CHANNEL,
type ReleaseChannel,
requireVersion,
STABLE_CHANNEL,
versionChannel,
} from "./aidlc-channel.ts";
import { machineTransactionRoot } from "./aidlc-install-paths.ts";
import {
type MachineConfig,
readMachineChannel,
readMachineConfig,
resolvedReleaseSettings,
updateCachePath,
} from "./aidlc-machine-config.ts";
import {
fetchReleaseMetadata,
ReleaseUnavailableError,
resolvePreviewVersion,
} from "./aidlc-release.ts";
import {
executePlan,
transactionState,
writeOperation,
} from "./aidlc-transaction.ts";
import { aidlcInvocation } from "./aidlc-runtime-paths.ts";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export type UpdateCache = {
schemaVersion: 1;
checkedAt: string;
latestVersion: string;
releaseDate: string;
// Caches written before release channels existed carry no channel and
// describe the stable stream.
channel?: ReleaseChannel;
};
export type UpdateState = {
state:
| "current"
| "behind"
| "stale"
| "disabled"
| "offline"
| "unavailable"
| "absent"
| "invalid-config";
currentVersion: string;
channel: ReleaseChannel;
latestVersion?: string;
checkedAt?: string;
stale?: boolean;
message: string;
};
function validateCache(value: unknown): UpdateCache {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("update cache must be a JSON object");
}
const record = value as Record<string, unknown>;
const allowed = new Set(["schemaVersion", "checkedAt", "latestVersion", "releaseDate", "channel"]);
const unknown = Object.keys(record).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new Error(`update cache contains unknown key(s): ${unknown.join(", ")}`);
}
if (
record.schemaVersion !== 1 ||
typeof record.checkedAt !== "string" ||
!Number.isFinite(Date.parse(record.checkedAt)) ||
typeof record.latestVersion !== "string" ||
typeof record.releaseDate !== "string" ||
!/^\d{4}-\d{2}-\d{2}$/.test(record.releaseDate) ||
(record.channel !== undefined && !isReleaseChannel(record.channel))
) {
throw new Error("update cache has an invalid schema");
}
requireVersion(record.latestVersion);
if (versionChannel(record.latestVersion) !== (record.channel ?? STABLE_CHANNEL)) {
throw new Error("update cache version does not belong to its channel");
}
return record as UpdateCache;
}
export function readUpdateCache(): UpdateCache | null {
const path = updateCachePath();
if (!existsSync(path)) return null;
return validateCache(JSON.parse(readFileSync(path, "utf-8")));
}
// The machine is "behind" when the channel's newest release is newer than the
// running binary, or when the binary belongs to the other channel: a stable
// binary on the preview channel (or the reverse) converges through `update`
// even when the target id sorts lower, and that is a channel switch, not a
// downgrade.
function cacheState(cache: UpdateCache, now = Date.now()): UpdateState {
const channel = cache.channel ?? STABLE_CHANNEL;
const binaryChannel = versionChannel(AIDLC_VERSION);
const stale = now - Date.parse(cache.checkedAt) >= CACHE_TTL_MS;
const switching = binaryChannel !== channel;
const behind = switching || compareVersions(AIDLC_VERSION, cache.latestVersion) < 0;
// Stable messages keep their pre-channel wording; preview names its channel.
const channelWord = channel === STABLE_CHANNEL ? "" : `${channel} `;
return {
state: behind ? "behind" : stale ? "stale" : "current",
currentVersion: AIDLC_VERSION,
channel,
latestVersion: cache.latestVersion,
checkedAt: cache.checkedAt,
stale,
message: switching
? `binary ${AIDLC_VERSION} (${binaryChannel}), ${channel} channel newest ${cache.latestVersion}; update switches channels`
: behind
? `binary ${AIDLC_VERSION}, latest ${channelWord}${cache.latestVersion}`
: stale
? `binary ${AIDLC_VERSION}; update cache is stale`
: `binary ${AIDLC_VERSION} is ${channelWord ? `the latest ${channelWord}release` : "latest"}`,
};
}
export function cachedUpdateState(requested?: ReleaseChannel): UpdateState {
let config: MachineConfig;
let channel: ReleaseChannel;
try {
config = readMachineConfig();
channel = requested ?? readMachineChannel();
} catch (error) {
return {
state: "invalid-config",
currentVersion: AIDLC_VERSION,
channel: requested ?? STABLE_CHANNEL,
message: error instanceof Error ? error.message : String(error),
};
}
if (config["update-check"] === false) {
return {
state: "disabled",
currentVersion: AIDLC_VERSION,
channel,
message: "update checks disabled by global config",
};
}
const settings = resolvedReleaseSettings();
let cache: UpdateCache | null;
try {
cache = readUpdateCache();
} catch {
return {
state: "unavailable",
currentVersion: AIDLC_VERSION,
channel,
message: "update cache is invalid",
};
}
// A cache written for the other channel describes a stream this machine no
// longer follows; it is not evidence about the selected channel.
if (cache && (cache.channel ?? STABLE_CHANNEL) === channel) return cacheState(cache);
return {
state: settings.offline ? "offline" : "absent",
currentVersion: AIDLC_VERSION,
channel,
message: settings.offline ? "update check unavailable while offline" : "update cache is absent",
};
}
export function cachedUpdateNotice(): string | null {
const state = cachedUpdateState();
return state.state === "behind" && state.latestVersion
? `Update available: aidlc ${state.latestVersion} (current ${AIDLC_VERSION}, ${state.channel} channel). Update with: ${aidlcInvocation()} update`
: null;
}
export async function refreshUpdateState(
timeoutMs: number,
overrides: {
offline?: boolean;
baseUrl?: string;
caBundle?: string;
channel?: ReleaseChannel;
apiUrl?: string;
} = {},
): Promise<UpdateState> {
let config: MachineConfig;
let channel: ReleaseChannel;
try {
config = readMachineConfig();
channel = overrides.channel ?? readMachineChannel();
} catch (error) {
return {
state: "invalid-config",
currentVersion: AIDLC_VERSION,
channel: overrides.channel ?? STABLE_CHANNEL,
message: error instanceof Error ? error.message : String(error),
};
}
if (config["update-check"] === false) return cachedUpdateState(channel);
const settings = resolvedReleaseSettings(overrides);
if (settings.offline) {
return {
state: "offline",
currentVersion: AIDLC_VERSION,
channel,
message: "update check unavailable while offline",
};
}
let release: Awaited<ReturnType<typeof fetchReleaseMetadata>> | null = null;
try {
const version = channel === PREVIEW_CHANNEL
? await resolvePreviewVersion({
baseUrl: settings.baseUrl,
apiUrl: overrides.apiUrl,
caBundle: settings.caBundle,
timeoutMs,
})
: undefined;
release = await fetchReleaseMetadata({
version,
offline: settings.offline,
baseUrl: settings.baseUrl,
caBundle: settings.caBundle,
metadataTimeoutMs: timeoutMs,
});
const cache: UpdateCache = validateCache({
schemaVersion: 1,
checkedAt: new Date().toISOString(),
latestVersion: release.manifest.version,
releaseDate: release.manifest.date,
channel,
});
const previousCache = (() => {
try {
return readUpdateCache();
} catch {
return null;
}
})();
// A stream regresses only against its own channel: the newest stable is
// expected to sort below a preview binary, and the other channel's cache
// says nothing about this one.
if (
(versionChannel(AIDLC_VERSION) === channel &&
compareVersions(cache.latestVersion, AIDLC_VERSION) < 0) ||
(previousCache &&
(previousCache.channel ?? STABLE_CHANNEL) === channel &&
compareVersions(cache.latestVersion, previousCache.latestVersion) < 0)
) {
throw new Error(
`release metadata regressed from ${
previousCache?.latestVersion ?? AIDLC_VERSION
} to ${cache.latestVersion}`,
);
}
const path = updateCachePath();
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: [writeOperation(
relative(root, path),
`${JSON.stringify(cache, null, 2)}\n`,
transactionState(path),
0o600,
)],
});
return cacheState(cache);
} catch (error) {
const previous = cachedUpdateState(channel);
if (previous.state === "behind" || previous.state === "current" || previous.state === "stale") {
return {
...previous,
state: "unavailable",
message:
`update refresh unavailable; cached version ${previous.latestVersion} is stale or unverifiable`,
};
}
return {
state: "unavailable",
currentVersion: AIDLC_VERSION,
channel,
message: error instanceof ReleaseUnavailableError
? error.message
: `update refresh failed: ${error instanceof Error ? error.message : String(error)}`,
};
} finally {
if (release?.cleanup) rmSync(release.cleanup, { recursive: true, force: true });
}
}