/** * Safe dependency upgrade script. * * Only upgrades to versions published at least 2 weeks ago, * reducing risk from supply chain attacks via newly-hijacked packages. * * Usage: * deno run --allow-read --allow-write --allow-net scripts/safe-upgrade.ts * deno run --allow-read --allow-write --allow-net scripts/safe-upgrade.ts --dry-run * deno run --allow-read --allow-write --allow-net scripts/safe-upgrade.ts --min-age-days=30 */ import { errorMessage } from "#shared/error-message.ts"; import { requireValue } from "#shared/required-value.ts"; const DEFAULT_MIN_AGE_DAYS = 14; const DENO_JSON_PATH = new URL("../deno.json", import.meta.url).pathname; interface VersionInfo { publishedAt: Date; version: string; } interface UpgradeResult { currentSpec: string; currentVersion: string | null; error: string | null; name: string; newPublishedAt: Date | null; newVersion: string | null; registry: "npm" | "jsr"; skippedNewer: string | null; } interface AvailableUpgrade extends UpgradeResult { newPublishedAt: Date; newVersion: string; } function parseArgs(): { dryRun: boolean; minAgeDays: number } { let dryRun = false; let minAgeDays = DEFAULT_MIN_AGE_DAYS; for (const arg of Deno.args) { if (arg === "--dry-run") dryRun = true; else if (arg.startsWith("--min-age-days=")) { minAgeDays = Number.parseInt(arg.slice("--min-age-days=".length), 10); if (Number.isNaN(minAgeDays) || minAgeDays < 0) { console.error("Invalid --min-age-days value"); Deno.exit(1); } } } return { dryRun, minAgeDays }; } /** Extract the base version from a specifier like ^0.17.0, ^5, ~1.2, or 1 */ function parseVersionSpec(spec: string): { prefix: string; version: string; } | null { const match = spec.match(/^([~^]?)(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); if (!match) return null; const prefix = requireValue(match[1], "Version prefix capture is missing"); const major = requireValue(match[2], "Version major capture is missing"); const minor = match[3] ?? "0"; const patch = match[4] ?? "0"; return { prefix, version: `${major}.${minor}.${patch}` }; } const versionParts = (version: string): [number, number, number] => { const parts = version.split("."); if (parts.length !== 3) throw new Error(`Invalid version: ${version}`); return [ Number(requireValue(parts[0], `Invalid version: ${version}`)), Number(requireValue(parts[1], `Invalid version: ${version}`)), Number(requireValue(parts[2], `Invalid version: ${version}`)), ]; }; function compareVersions(a: string, b: string): number { const [majorA, minorA, patchA] = versionParts(a); const [majorB, minorB, patchB] = versionParts(b); if (majorA !== majorB) return majorA - majorB; if (minorA !== minorB) return minorA - minorB; return patchA - patchB; } async function fetchNpmVersions(pkg: string): Promise { const resp = await fetch(`https://registry.npmjs.org/${pkg}`); if (!resp.ok) { throw new Error(`npm registry returned ${resp.status} for ${pkg}`); } const data = await resp.json(); const time = data.time as Record; const versions: VersionInfo[] = []; for (const [ver, dateStr] of Object.entries(time)) { if (ver === "created" || ver === "modified") continue; // Skip pre-release versions if (/[-+]/.test(ver)) continue; if (!/^\d+\.\d+\.\d+$/.test(ver)) continue; versions.push({ publishedAt: new Date(dateStr), version: ver }); } return versions; } async function fetchJsrVersions( scope: string, name: string, ): Promise { const resp = await fetch( `https://jsr.io/api/scopes/${scope}/packages/${name}/versions`, ); if (!resp.ok) { throw new Error(`JSR API returned ${resp.status} for @${scope}/${name}`); } const raw = await resp.json(); const data = (Array.isArray(raw) ? raw : (raw.items ?? [])) as Array<{ version: string; createdAt: string; }>; const versions: VersionInfo[] = []; for (const entry of data) { if (/[-+]/.test(entry.version)) continue; if (!/^\d+\.\d+\.\d+$/.test(entry.version)) continue; versions.push({ publishedAt: new Date(entry.createdAt), version: entry.version, }); } return versions; } /** * Split an "npm:"/"jsr:" specifier into its package name and version range. * Returns an error string when the specifier is malformed or unrecognized. */ function parsePackageSpecifier( specifier: string, ): { pkgName: string; versionRange: string } | { error: string } { if (!specifier.startsWith("npm:") && !specifier.startsWith("jsr:")) { return { error: "unknown registry" }; } const withoutPrefix = specifier.slice(4); const atIdx = withoutPrefix.lastIndexOf("@"); if (atIdx <= 0) { return { error: "no version specifier found" }; } return { pkgName: withoutPrefix.slice(0, atIdx), versionRange: withoutPrefix.slice(atIdx + 1), }; } /** Fetch all stable versions for a package from its registry. */ function fetchVersionsFor( registry: "npm" | "jsr", pkgName: string, ): Promise { if (registry === "npm") { return fetchNpmVersions(pkgName); } const withoutAt = pkgName.startsWith("@") ? pkgName.slice(1) : pkgName; const slashIndex = withoutAt.indexOf("/"); if (slashIndex <= 0 || slashIndex === withoutAt.length - 1) { throw new Error(`Invalid JSR package name: ${pkgName}`); } return fetchJsrVersions( withoutAt.slice(0, slashIndex), withoutAt.slice(slashIndex + 1), ); } async function checkUpgrade( name: string, specifier: string, cutoffDate: Date, ): Promise { const result: UpgradeResult = { currentSpec: specifier, currentVersion: null, error: null, name, newPublishedAt: null, newVersion: null, registry: specifier.startsWith("npm:") ? "npm" : "jsr", skippedNewer: null, }; try { const parsedSpecifier = parsePackageSpecifier(specifier); if ("error" in parsedSpecifier) { result.error = parsedSpecifier.error; return result; } const { pkgName, versionRange } = parsedSpecifier; const parsed = parseVersionSpec(versionRange); if (!parsed) { result.error = `can't parse version spec: ${versionRange}`; return result; } result.currentVersion = parsed.version; const allVersions = await fetchVersionsFor(result.registry, pkgName); // Sort all stable versions descending — no semver range filtering, // we want the latest version overall (like `deno outdated --update --latest`) const sorted = allVersions.sort((a, b) => compareVersions(b.version, a.version), ); const latest = sorted[0]; if (latest === undefined) { result.error = "no versions found"; return result; } // Latest version that's old enough const safe = sorted.find((v) => v.publishedAt <= cutoffDate); if (!safe || compareVersions(safe.version, result.currentVersion) <= 0) { // Already at or ahead of the latest safe version return result; } result.newVersion = safe.version; result.newPublishedAt = safe.publishedAt; // Note if we skipped a newer version because it's too fresh if (latest.version !== safe.version) { result.skippedNewer = latest.version; } return result; } catch (e) { result.error = errorMessage(e); return result; } } const hasUpgrade = (result: UpgradeResult): result is AvailableUpgrade => { if ((result.newVersion === null) !== (result.newPublishedAt === null)) { throw new Error(`Incomplete upgrade result for ${result.name}`); } return result.newVersion !== null; }; async function main() { const { dryRun, minAgeDays } = parseArgs(); const cutoffDate = new Date(Date.now() - minAgeDays * 24 * 60 * 60 * 1000); console.log( `\nSafe upgrade: only versions published before ${cutoffDate .toISOString() .slice(0, 10)} (${minAgeDays}+ days old)`, ); if (dryRun) console.log("DRY RUN — no changes will be written\n"); else console.log(""); const denoJson = JSON.parse(await Deno.readTextFile(DENO_JSON_PATH)); const imports = denoJson.imports as Record; // Collect external dependencies (npm: and jsr:) const deps = Object.entries(imports).filter( ([, spec]) => spec.startsWith("npm:") || spec.startsWith("jsr:"), ); console.log(`Found ${deps.length} external dependencies to check\n`); const results = await Promise.all( deps.map(([name, spec]) => checkUpgrade(name, spec, cutoffDate)), ); const upgrades = results.filter(hasUpgrade); const errors = results.filter((r) => r.error !== null); const upToDate = results.filter( (r) => r.newVersion === null && r.error === null, ); printUpToDate(upToDate); printUpgrades(upgrades, minAgeDays); printErrors(errors); if (upgrades.length === 0) { console.log("Everything is up to date!"); } else if (!dryRun) { await applyUpgrades(upgrades); } } /** Print a headed list of results, or nothing when the list is empty. */ const printResults = ( heading: string, line: (result: UpgradeResult) => string, ): ((results: UpgradeResult[]) => void) => (results) => { if (results.length === 0) return; console.log(`${heading} (${results.length}):`); for (const r of results) console.log(line(r)); console.log(""); }; const printUpToDate = printResults( "Up to date", (r) => ` ${r.name} @ ${r.currentVersion}`, ); function printUpgrades(upgrades: AvailableUpgrade[], minAgeDays: number): void { if (upgrades.length === 0) return; console.log(`Upgrades available (${upgrades.length}):`); for (const r of upgrades) { const age = Math.floor( (Date.now() - r.newPublishedAt.getTime()) / (1000 * 60 * 60 * 24), ); console.log( ` ${r.name}: ${r.currentVersion} -> ${r.newVersion} (${age} days old)`, ); if (r.skippedNewer) { console.log( ` ⚠ skipped ${r.skippedNewer} (too recent, < ${minAgeDays} days)`, ); } } console.log(""); } const printErrors = printResults("Errors", (r) => ` ${r.name}: ${r.error}`); async function applyUpgrades(upgrades: AvailableUpgrade[]): Promise { let denoJsonText = await Deno.readTextFile(DENO_JSON_PATH); for (const r of upgrades) { // Replace the full specifier, updating to ^newVersion const oldSpec = r.currentSpec; const versionPart = oldSpec.slice(oldSpec.lastIndexOf("@") + 1); const newSpec = oldSpec.replace(versionPart, `^${r.newVersion}`); denoJsonText = denoJsonText.replace( JSON.stringify(oldSpec), JSON.stringify(newSpec), ); } await Deno.writeTextFile(DENO_JSON_PATH, denoJsonText); console.log(`Updated deno.json with ${upgrades.length} upgrade(s)`); console.log("Run 'deno task precommit' to verify everything still works"); } main();