Files
kandev/scripts/update-agent-runtime-pins.mjs
T

163 lines
5.3 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const DEFAULT_CATALOGUE_PATH = path.resolve(
SCRIPT_DIR,
"../apps/backend/internal/agent/agents/managed_npm_runtime_versions.json",
);
const STABLE_SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
export function parseStableVersion(value) {
if (typeof value !== "string" || !STABLE_SEMVER.test(value)) {
throw new Error(`value is not a stable SemVer: ${String(value)}`);
}
return value;
}
function assertCatalogue(catalogue) {
if (!catalogue || typeof catalogue !== "object" || Array.isArray(catalogue)) {
throw new Error("runtime pin catalogue must be a JSON object");
}
const packages = Object.keys(catalogue);
if (packages.length === 0) {
throw new Error("runtime pin catalogue is empty");
}
for (const packageName of packages) {
if (!packageName.trim()) {
throw new Error("runtime pin catalogue contains an empty package name");
}
parseStableVersion(catalogue[packageName]);
}
}
export function updatePins(catalogue, latestByPackage) {
assertCatalogue(catalogue);
if (!latestByPackage || typeof latestByPackage !== "object") {
throw new Error("latest version lookup did not return an object");
}
const next = {};
let changed = false;
for (const packageName of Object.keys(catalogue)) {
const latest = latestByPackage[packageName];
if (latest === undefined || latest === null || latest === "") {
throw new Error(`latest version is missing for ${packageName}`);
}
parseStableVersion(latest);
next[packageName] = latest;
changed ||= latest !== catalogue[packageName];
}
return { catalogue: next, changed };
}
export function formatCatalogue(catalogue) {
assertCatalogue(catalogue);
return `${JSON.stringify(catalogue, Object.keys(catalogue).sort(), 2)}\n`;
}
async function fetchLatest(packageName, fetchImpl) {
const response = await fetchImpl(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
{ headers: { accept: "application/json" } },
);
if (!response.ok) {
throw new Error(`npm lookup for ${packageName} returned HTTP ${response.status}`);
}
const metadata = await response.json();
const latest = metadata?.["dist-tags"]?.latest;
parseStableVersion(latest);
return latest;
}
export async function fetchLatestVersions(packages, fetchImpl = globalThis.fetch) {
if (typeof fetchImpl !== "function") {
throw new Error("fetch is unavailable");
}
const pairs = await Promise.all(
packages.map(async (packageName) => [packageName, await fetchLatest(packageName, fetchImpl)]),
);
return Object.fromEntries(pairs);
}
export async function readCatalogue(cataloguePath = DEFAULT_CATALOGUE_PATH) {
const raw = await fs.readFile(cataloguePath, "utf8");
const catalogue = JSON.parse(raw);
assertCatalogue(catalogue);
return catalogue;
}
export async function writeCatalogueAtomically(cataloguePath, catalogue) {
const directory = path.dirname(cataloguePath);
const temporaryPath = path.join(
directory,
`.${path.basename(cataloguePath)}.${process.pid}.tmp`,
);
await fs.writeFile(temporaryPath, formatCatalogue(catalogue), "utf8");
await fs.rename(temporaryPath, cataloguePath);
}
export async function updateCatalogue({
cataloguePath = DEFAULT_CATALOGUE_PATH,
fetchImpl = globalThis.fetch,
} = {}) {
const catalogue = await readCatalogue(cataloguePath);
const latestByPackage = await fetchLatestVersions(Object.keys(catalogue), fetchImpl);
const result = updatePins(catalogue, latestByPackage);
if (result.changed) {
await writeCatalogueAtomically(cataloguePath, result.catalogue);
}
return result;
}
function outputChanged(outputPath, changed) {
if (!outputPath) {
return;
}
return fs.appendFile(outputPath, `changed=${changed ? "true" : "false"}\n`);
}
export function parseArguments(argv) {
let cataloguePath = DEFAULT_CATALOGUE_PATH;
let outputPath = "";
let positionalPathSeen = false;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--github-output") {
outputPath = argv[index + 1] ?? "";
if (!outputPath || outputPath.startsWith("--")) {
throw new Error("--github-output requires a path");
}
index += 1;
continue;
}
if (argument.startsWith("--")) {
throw new Error(`unknown option: ${argument}`);
}
if (positionalPathSeen) {
throw new Error("only one catalogue path is supported");
}
cataloguePath = argument;
positionalPathSeen = true;
}
return { cataloguePath, outputPath };
}
async function main(argv = process.argv.slice(2)) {
const { cataloguePath, outputPath } = parseArguments(argv);
const result = await updateCatalogue({ cataloguePath });
await outputChanged(outputPath, result.changed);
process.stdout.write(result.changed ? "Managed runtime pins changed.\n" : "Managed runtime pins are current.\n");
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`Managed runtime pin update failed: ${error.message}`);
process.exitCode = 1;
});
}