import fs from 'node:fs' import path from 'node:path' import { compareSemver } from '@contrast/runtime-delivery' import { getCliVersion } from '../src/cli-version' import { cliUpdateFilePath } from '../src/home-paths' import { isAgentEnv } from './commands/inspect/env' export const DEFAULT_CLI_RELEASE_ORIGIN = 'https://r2.rnxsim.com/rnx-cli' export const CLI_UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000 export const CLI_UPDATE_NOTIFICATION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000 const CLI_UPDATE_STATE_SCHEMA = 1 as const const CLI_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ let stateWriteSequence = 0 interface CliUpdateState { schema: typeof CLI_UPDATE_STATE_SCHEMA latestVersion: string | null checkedAt: number lastAttemptAt: number lastNotifiedVersion: string | null lastNotifiedAt: number } export interface CliUpdateAvailability { currentVersion: string latestVersion: string outdated: boolean } export interface CliUpdateInvocation { command: string | null commandArgs: readonly string[] exitCode: number standalone: boolean stderrIsTTY: boolean stdoutIsTTY: boolean version: boolean env?: Readonly> } function emptyState(): CliUpdateState { return { schema: CLI_UPDATE_STATE_SCHEMA, latestVersion: null, checkedAt: 0, lastAttemptAt: 0, lastNotifiedVersion: null, lastNotifiedAt: 0, } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' } function isTimestamp(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 } function readState(): CliUpdateState { try { const parsed: unknown = JSON.parse(fs.readFileSync(cliUpdateFilePath(), 'utf8')) if ( !isRecord(parsed) || parsed.schema !== CLI_UPDATE_STATE_SCHEMA || (parsed.latestVersion !== null && (typeof parsed.latestVersion !== 'string' || !CLI_VERSION_PATTERN.test(parsed.latestVersion))) || !isTimestamp(parsed.checkedAt) || !isTimestamp(parsed.lastAttemptAt) || (parsed.lastNotifiedVersion !== null && (typeof parsed.lastNotifiedVersion !== 'string' || !CLI_VERSION_PATTERN.test(parsed.lastNotifiedVersion))) || !isTimestamp(parsed.lastNotifiedAt) ) { return emptyState() } return { schema: CLI_UPDATE_STATE_SCHEMA, latestVersion: parsed.latestVersion, checkedAt: parsed.checkedAt, lastAttemptAt: parsed.lastAttemptAt, lastNotifiedVersion: parsed.lastNotifiedVersion, lastNotifiedAt: parsed.lastNotifiedAt, } } catch { return emptyState() } } function writeState(state: CliUpdateState): boolean { const file = cliUpdateFilePath() const temporary = `${file}.tmp-${process.pid}-${stateWriteSequence++}` try { fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, 'utf8') fs.renameSync(temporary, file) return true } catch { try { fs.rmSync(temporary, { force: true }) } catch {} return false } } function availability( state: CliUpdateState, currentVersion = getCliVersion(), ): CliUpdateAvailability | null { if (!state.latestVersion) return null return { currentVersion, latestVersion: state.latestVersion, outdated: compareSemver(state.latestVersion, currentVersion) > 0, } } function envFlagIsOff(value: string | undefined): boolean { return ( value === '0' || value?.toLowerCase() === 'false' || value?.toLowerCase() === 'off' ) } function envFlagIsOn(value: string | undefined): boolean { return Boolean(value) && !envFlagIsOff(value) } export function automaticCliUpdateChecksEnabled( env: Readonly> = process.env, ): boolean { return !envFlagIsOff(env.RNX_UPDATE_CHECK) } export function shouldUseAutomaticCliUpdateCheck( invocation: CliUpdateInvocation, ): boolean { const env = invocation.env ?? process.env if ( !invocation.standalone || invocation.exitCode !== 0 || invocation.version || !invocation.stdoutIsTTY || !invocation.stderrIsTTY || !automaticCliUpdateChecksEnabled(env) || envFlagIsOn(env.CI) || envFlagIsOn(env.GITHUB_ACTIONS) || envFlagIsOn(env.RNX_NO_PROMPT) || isAgentEnv(env) || invocation.commandArgs.includes('--json') || invocation.commandArgs.includes('--ci') || invocation.commandArgs.includes('--dry-run') ) { return false } return !['agent-wrapper', 'cleanup', 'daemon', 'serve', 'upgrade', 'version'].includes( invocation.command ?? '', ) } export function claimAutomaticCliUpdateCheck(now = Date.now()): boolean { const state = readState() const lastCheck = Math.max(state.checkedAt, state.lastAttemptAt) if (now >= lastCheck && now - lastCheck < CLI_UPDATE_CHECK_INTERVAL_MS) { return false } return writeState({ ...state, lastAttemptAt: now }) } export function readCachedCliUpdate( currentVersion = getCliVersion(), ): CliUpdateAvailability | null { return availability(readState(), currentVersion) } export function takeCliUpdateNotification( currentVersion = getCliVersion(), now = Date.now(), ): CliUpdateAvailability | null { const state = readState() const update = availability(state, currentVersion) if (!update?.outdated || state.lastNotifiedVersion === update.latestVersion) return null if ( now >= state.lastNotifiedAt && now - state.lastNotifiedAt < CLI_UPDATE_NOTIFICATION_COOLDOWN_MS ) { return null } if ( !writeState({ ...state, lastNotifiedVersion: update.latestVersion, lastNotifiedAt: now, }) ) { return null } return update } export async function fetchCliReleaseFile( url: string, timeoutMs: number, ): Promise { let response: Response try { response = await fetch(url, { headers: { accept: 'application/octet-stream' }, signal: AbortSignal.timeout(timeoutMs), }) } catch (error) { throw new Error( `could not reach the rnx update service: ${ error instanceof Error ? error.message : String(error) }`, ) } if (!response.ok) { throw new Error( `rnx update download failed: ${response.status} ${response.statusText}`, ) } return Buffer.from(await response.arrayBuffer()) } export async function refreshCliUpdateCache( options: { releaseOrigin?: string; now?: number; timeoutMs?: number } = {}, ): Promise { const releaseOrigin = (options.releaseOrigin ?? DEFAULT_CLI_RELEASE_ORIGIN).replace( /\/+$/, '', ) const latestVersion = ( await fetchCliReleaseFile( `${releaseOrigin}/channels/stable`, options.timeoutMs ?? 8_000, ) ) .toString('utf8') .trim() if (!CLI_VERSION_PATTERN.test(latestVersion)) { throw new Error( `rnx update service returned an invalid version: ${latestVersion || '(empty)'}`, ) } const now = options.now ?? Date.now() const state = readState() const next: CliUpdateState = { ...state, latestVersion, checkedAt: now, lastAttemptAt: now, } writeState(next) const update = availability(next) if (!update) throw new Error('rnx update cache did not retain the latest version') return update }