Ctrl K
ComunidadGuías…

scripts/safe-upgrade.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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<VersionInfo[]> {
  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<string, string>;
  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<VersionInfo[]> {
  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<VersionInfo[]> {
  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<UpgradeResult> {
  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<string, string>;

  // 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<void> {
  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();