// --------------------------------------------------------------------------- // 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 | null; }; const semverPattern = /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?[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 | null, right: ReadonlyArray | 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>; export type ResolveDistTagsOptions = { /** Env source; defaults to `process.env`. Injectable for tests. */ readonly env?: Record; /** 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 => { const globalProcess = ( globalThis as { readonly process?: { readonly env?: Record } } ).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; 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): 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 => { 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 => { 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 }; };