286 lines · 11.5 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Update check: one source of truth for "is a newer Executor published?"//// Both surfaces that tell a user to upgrade read from here://   - the CLI prints an "update available" line under its ready banner, and//   - the web/desktop shell lights up its sidebar UpdateCard,// the latter by fetching the `/v1/app/npm/dist-tags` route (see// `server/npm-dist-tags.ts`), which serves `resolveDistTags()` verbatim.//// The semver helpers are a deliberate port of the ones the web shell already// carries (`packages/app/src/web/shell.tsx`) so the client- and server-side// "is this newer?" verdicts can never disagree.//// Resolution order (see `resolveDistTags`)://   1. `EXECUTOR_DISABLE_UPDATE_CHECK` set  -> {} (no check, both surfaces quiet)//   2. `EXECUTOR_NPM_DIST_TAGS` JSON override -> parsed tags (tests / air-gapped)//   3. `EXECUTOR_FORCE_LATEST_VERSION` single value -> { latest, beta } = value//   4. npm registry dist-tags endpoint, cached in-process//   5. any failure -> {} (the check is best-effort; never a hard error)// --------------------------------------------------------------------------- export type UpdateChannel = "latest" | "beta"; /** The published npm package the CLI ships as. */export const EXECUTOR_PACKAGE_NAME = "executor"; /** Lightweight dist-tags-only registry endpoint (not the full packument). */const NPM_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${EXECUTOR_PACKAGE_NAME}/dist-tags`; /** How long a successful registry lookup is reused before refetching. */const DIST_TAGS_TTL_MS = 10 * 60 * 1000; /** How long an empty result (offline, timeout, no tags) is reused before *  retrying. Negative-caches failures so an air-gapped server does not pay the *  fetch timeout on every shell load, while still recovering quickly. */const EMPTY_TTL_MS = 60 * 1000; /** How long to wait on the registry before giving up (a check, not a gate). */const NPM_FETCH_TIMEOUT_MS = 1500; // ── Semver (ported from the web shell, kept byte-for-byte in behaviour) ──── type ParsedVersion = {  readonly major: number;  readonly minor: number;  readonly patch: number;  readonly prerelease: ReadonlyArray<string | number> | null;}; const semverPattern =  /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; export const resolveUpdateChannel = (version: string): UpdateChannel =>  version.includes("-beta.") ? "beta" : "latest"; const parseVersion = (version: string): ParsedVersion | null => {  const match = version.trim().match(semverPattern);  if (!match?.groups) return null;  return {    major: Number(match.groups.major),    minor: Number(match.groups.minor),    patch: Number(match.groups.patch),    prerelease: match.groups.prerelease      ? match.groups.prerelease.split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id))      : null,  };}; const comparePrereleaseIdentifiers = (  left: ReadonlyArray<string | number> | null,  right: ReadonlyArray<string | number> | null,): number => {  if (left === null && right === null) return 0;  if (left === null) return 1;  if (right === null) return -1;  const max = Math.max(left.length, right.length);  for (let i = 0; i < max; i++) {    const l = left[i];    const r = right[i];    if (l === undefined) return -1;    if (r === undefined) return 1;    if (l === r) continue;    if (typeof l === "number" && typeof r === "number") return l < r ? -1 : 1;    if (typeof l === "number") return -1;    if (typeof r === "number") return 1;    return l < r ? -1 : 1;  }  return 0;}; /** * `-1` if `left` is older than `right`, `1` if newer, `0` if equal, `null` if * either side is not parseable semver. */export const compareVersions = (left: string, right: string): number | null => {  const lv = parseVersion(left);  const rv = parseVersion(right);  if (!lv || !rv) return null;  if (lv.major !== rv.major) return lv.major < rv.major ? -1 : 1;  if (lv.minor !== rv.minor) return lv.minor < rv.minor ? -1 : 1;  if (lv.patch !== rv.patch) return lv.patch < rv.patch ? -1 : 1;  return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);}; /** * True when `version` parses as `0.0.0`, with or without a prerelease suffix * (e.g. `0.0.0-dev`). `0.0.0` is never a published release — it is the * build-time fallback baked in when the real version was unavailable at * build time — so a build carrying it must never claim an update. */export const isUnstampedVersion = (version: string): boolean => {  const parsed = parseVersion(version);  return parsed !== null && parsed.major === 0 && parsed.minor === 0 && parsed.patch === 0;}; /** * The dist-tag channel `version` can legitimately be compared against, or * `null` when there is none (the check should be suppressed, not run): *   - an unstamped build (0.0.0*) never shipped, so there is nothing to *     compare it against *   - unparseable input has no meaningful channel *   - no prerelease -> the `latest` tag *   - a prerelease whose first identifier is `beta` -> the `beta` tag *   - any other prerelease (rc, alpha, next, dev, ...) has no matching *     dist-tag, so suppress rather than nag forever against a channel it can *     never catch up to */export const resolveComparisonChannel = (version: string): UpdateChannel | null => {  if (isUnstampedVersion(version)) return null;  const parsed = parseVersion(version);  if (!parsed) return null;  if (parsed.prerelease === null) return "latest";  return parsed.prerelease[0] === "beta" ? "beta" : null;}; /** * The single "should we nag?" verdict both the CLI and the web UpdateCard * read. False whenever there is nothing to compare: no current version, no * dist-tag channel this version could match (see `resolveComparisonChannel`), * or no published tag for that channel. */export const isUpdateAvailable = (  currentVersion: string | undefined,  latestVersion: string | null,): boolean => {  if (currentVersion === undefined) return false;  if (resolveComparisonChannel(currentVersion) === null) return false;  if (latestVersion === null) return false;  return compareVersions(currentVersion, latestVersion) === -1;}; // ── dist-tags resolution ────────────────────────────────────────────────── export type DistTags = Partial<Record<UpdateChannel, string>>; export type ResolveDistTagsOptions = {  /** Env source; defaults to `process.env`. Injectable for tests. */  readonly env?: Record<string, string | undefined>;  /** Fetch impl; defaults to global `fetch`. Injectable for tests. */  readonly fetchImpl?: typeof fetch;}; type CacheEntry = { readonly at: number; readonly tags: DistTags };let registryCache: CacheEntry | null = null; // Read `process.env` without a hard `process` reference, so this module stays// isomorphic (it is pulled into the browser-side type graph via the shared web// shell's update card, which has no node types).const ambientEnv = (): Record<string, string | undefined> => {  const globalProcess = (    globalThis as { readonly process?: { readonly env?: Record<string, string | undefined> } }  ).process;  return globalProcess?.env ?? {};}; /** Reset the in-process registry cache. Test seam. */export const __resetDistTagsCache = (): void => {  registryCache = null;}; const pickTags = (value: unknown): DistTags => {  if (typeof value !== "object" || value === null) return {};  const record = value as Record<string, unknown>;  const tags: DistTags = {};  if (typeof record.latest === "string") tags.latest = record.latest;  if (typeof record.beta === "string") tags.beta = record.beta;  return tags;}; const parseTagsJson = (raw: string): DistTags | null => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an operator-supplied env override is untrusted text; a malformed value is ignored, not fatal  try {    // oxlint-disable-next-line executor/no-json-parse -- boundary: untrusted env override, structurally validated by pickTags below    return pickTags(JSON.parse(raw));  } catch {    return null;  }}; const overrideFromEnv = (env: Record<string, string | undefined>): DistTags | null => {  const raw = env.EXECUTOR_NPM_DIST_TAGS?.trim();  if (raw) {    const parsed = parseTagsJson(raw);    if (parsed) return parsed;  }  const forced = env.EXECUTOR_FORCE_LATEST_VERSION?.trim();  if (forced) return { latest: forced, beta: forced };  return null;}; /** * Resolve the published dist-tags for the `executor` package. Best-effort: * returns `{}` when disabled, offline, timed out, or the registry misbehaves, * never throws. */export const resolveDistTags = async (options?: ResolveDistTagsOptions): Promise<DistTags> => {  const env = options?.env ?? ambientEnv();  if (env.EXECUTOR_DISABLE_UPDATE_CHECK) return {};   const override = overrideFromEnv(env);  if (override) return override;   const now = Date.now();  if (registryCache) {    // Empty results (failures, or a package with no tags) carry the shorter    // negative TTL so a transient outage recovers quickly; real tags stick.    const ttl =      registryCache.tags.latest || registryCache.tags.beta ? DIST_TAGS_TTL_MS : EMPTY_TTL_MS;    if (now - registryCache.at < ttl) return registryCache.tags;  }   const fetchImpl = options?.fetchImpl ?? globalThis.fetch;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort fetch of an external registry; any failure (offline, timeout, bad JSON) collapses to "no update signal", never an error  try {    const res = await fetchImpl(NPM_DIST_TAGS_URL, {      headers: { accept: "application/json" },      signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),    });    if (!res.ok) {      registryCache = { at: now, tags: {} };      return {};    }    const tags = pickTags(await res.json());    registryCache = { at: now, tags };    return tags;  } catch {    registryCache = { at: now, tags: {} };    return {};  }}; // ── checkForUpdate ───────────────────────────────────────────────────────── export type UpdateStatus = {  readonly updateAvailable: boolean;  readonly currentVersion: string;  readonly latestVersion: string | null;  readonly channel: UpdateChannel;  /** Suggested upgrade command, e.g. `npm i -g executor@latest`. */  readonly command: string;}; /** * Compare a running version against the published dist-tags for its channel. * Best-effort: an unreachable registry yields `updateAvailable: false`. */export const checkForUpdate = async (  currentVersion: string,  options?: ResolveDistTagsOptions,): Promise<UpdateStatus> => {  const channel = resolveUpdateChannel(currentVersion);  const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;   // No dist-tag can legitimately be compared against this version (unstamped  // dev build, or a prerelease channel that never gets published) — do not  // even hit the registry, since the answer is already known.  const comparisonChannel = resolveComparisonChannel(currentVersion);  if (comparisonChannel === null) {    return { updateAvailable: false, currentVersion, latestVersion: null, channel, command };  }   const tags = await resolveDistTags(options);  const latestVersion = tags[comparisonChannel] ?? null;  const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);  return { updateAvailable, currentVersion, latestVersion, channel, command };};