// rnx upgrade updates the separately delivered runtime first, then atomically // replaces the standalone cli executable. runtime stays first because replacing // the running cli's files before later dynamic imports can strand the command. import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { compareSemver } from '@contrast/runtime-delivery' import { getCliVersion } from '../../src/cli-version' import { isSootsimDevCheckout, readActiveRuntime, runtimeDir } from '../../src/home-paths' import { resolveRNXAppConfig } from '../app-config' import { DEFAULT_CLI_RELEASE_ORIGIN, fetchCliReleaseFile, refreshCliUpdateCache, } from '../cli-update' import { fetchRuntimeReleaseNotes, formatRuntimeReleaseNotes } from '../runtime-notes' import { IS_STANDALONE } from '../standalone' import { runRuntime } from './runtime' const SHA256_PATTERN = /^[a-f0-9]{64}$/ export interface UpgradeOptions { cliExecutablePath?: string cliReleaseOrigin?: string standalone?: boolean includeReleaseNotes?: boolean } export async function runUpgrade( args: string[], options: UpgradeOptions = {}, ): Promise { const channelIdx = args.indexOf('--channel') const channel = channelIdx >= 0 && args[channelIdx + 1] ? args[channelIdx + 1] : undefined console.log('rnx upgrade\n') console.log('runtime:') const beforeRuntime = readActiveRuntime() const { config } = await resolveRNXAppConfig() const configuredRuntime = config?.runtimeVersion const configuredRuntimeWasInstalled = configuredRuntime ? fs.existsSync(path.join(runtimeDir(configuredRuntime), 'index.html')) : false if (configuredRuntime) { console.log(` project config: v${configuredRuntime}`) } const install = configuredRuntime && configuredRuntimeWasInstalled ? null : await runRuntime( [ 'install', ...(configuredRuntime ? [configuredRuntime, '--set-active=false'] : []), ...(channel ? ['--channel', channel] : []), ], {}, ) const installedRuntime = install?.version ?? configuredRuntime ?? readActiveRuntime() if (installedRuntime) { console.log( configuredRuntime ? configuredRuntimeWasInstalled ? ` result: v${installedRuntime} already installed for this project` : ` result: not installed → v${installedRuntime} for this project` : beforeRuntime && beforeRuntime !== installedRuntime ? ` result: v${beforeRuntime} → v${installedRuntime}` : !beforeRuntime ? ` result: not installed → v${installedRuntime}` : ` result: v${installedRuntime} already current`, ) } const releaseNotes = installedRuntime && options.includeReleaseNotes !== false ? await fetchRuntimeReleaseNotes(installedRuntime) : null console.log('\ncli:') await upgradeCli(options) if (installedRuntime && options.includeReleaseNotes !== false) { console.log(`\n${formatRuntimeReleaseNotes(installedRuntime, releaseNotes)}`) } } export async function upgradeCli(options: UpgradeOptions = {}): Promise { const standalone = options.standalone ?? IS_STANDALONE if (!standalone) { if (isSootsimDevCheckout()) { console.log(' running from the Contrast checkout; pull instead of upgrading') return } throw new Error( 'this is not the standalone rnx executable; reinstall from https://rnxsim.com/install.sh', ) } const releaseOrigin = (options.cliReleaseOrigin ?? DEFAULT_CLI_RELEASE_ORIGIN).replace( /\/+$/, '', ) const current = getCliVersion() const { latestVersion: latest } = await refreshCliUpdateCache({ releaseOrigin }) if (compareSemver(latest, current) <= 0) { console.log(` v${current} (latest)`) return } const artifact = cliArtifactName() const artifactUrl = `${releaseOrigin}/${latest}/${artifact}` const expectedSha = (await fetchCliReleaseFile(`${artifactUrl}.sha256`, 8_000)) .toString('utf8') .trim() if (!SHA256_PATTERN.test(expectedSha)) { throw new Error('rnx update service returned an invalid executable checksum') } console.log(` v${current} → v${latest}`) console.log(` downloading ${artifact}...`) const executable = await fetchCliReleaseFile(artifactUrl, 120_000) const actualSha = createHash('sha256').update(executable).digest('hex') if (actualSha !== expectedSha) { throw new Error( `downloaded rnx executable failed verification (expected ${expectedSha}, got ${actualSha})`, ) } const executablePath = path.resolve(options.cliExecutablePath ?? process.execPath) const tempDir = fs.mkdtempSync(path.join(path.dirname(executablePath), '.rnx-update-')) const candidatePath = path.join(tempDir, path.basename(executablePath)) try { fs.writeFileSync(candidatePath, executable, { mode: 0o755 }) fs.chmodSync(candidatePath, 0o755) verifyCandidate(candidatePath, latest) fs.renameSync(candidatePath, executablePath) } finally { fs.rmSync(tempDir, { recursive: true, force: true }) } console.log(` updated to v${latest}`) } function cliArtifactName(): string { const platform = process.platform === 'darwin' ? 'darwin' : process.platform if (platform !== 'darwin' && platform !== 'linux') { throw new Error('standalone rnx self-update currently supports macOS and Linux') } if (process.arch !== 'arm64' && process.arch !== 'x64') { throw new Error(`standalone rnx does not support ${process.arch}`) } return `rnx-${platform}-${process.arch}` } function verifyCandidate(candidatePath: string, expectedVersion: string): void { const result = spawnSync(candidatePath, ['--version'], { encoding: 'utf8', timeout: 15_000, }) const reported = result.stdout.match(/^rnx v([^\s]+)/m)?.[1] if (result.status !== 0 || reported !== expectedVersion) { throw new Error( `downloaded rnx executable reported ${reported ? `v${reported}` : 'no version'}; expected v${expectedVersion}`, ) } }