UNPKG

react-native

Version:

A framework for building native apps using React

1,529 lines • 54.6 kB
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */

'use strict';

/*:: import type {DownloadArgs, ResolvedArtifact, ProcessResult, ArtifactResultEntry} from './spm-types'; */

/**
 * download-spm-artifacts.js
 *
 * Downloads the three React Native iOS xcframeworks from Maven, extracts
 * them to a local cache directory, and writes artifacts.json for use by
 * generate-spm-package.js.
 *
 * Supports stable releases, nightlies, and snapshot builds, matching
 * the same resolution logic used by the existing CocoaPods scripts.
 *
 * Artifacts handled:
 *   React                  – react-native-core tarball from Maven
 *   ReactNativeDependencies – react-native-dependencies tarball from Maven
 *   hermes-engine          – hermes-ios tarball from Maven
 *
 * Usage:
 *   node scripts/download-spm-artifacts.js [options]
 *
 * Options:
 *   --version <ver>    RN version. Defaults to version in package.json.
 *                      Use "nightly" to resolve the latest nightly.
 *   --flavor  <f>      debug (default) or release.
 *   --output  <dir>    Where to write xcframeworks.
 *                      Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/
 *                      (downloaded tarballs are shared with CocoaPods in
 *                      ~/Library/Caches/ReactNative/; RCT_SKIP_CACHES=1 bypasses.)
 *
 * Per-artifact version overrides (mirrors existing env vars):
 *   HERMES_VERSION=<ver|nightly|latest-v1>
 *   RN_DEP_VERSION=<ver|nightly>
 *
 * Repository selection:
 *   Release tarballs are looked up on the React Native Maven mirror first,
 *   with Maven Central as the fallback when the mirror doesn't have the
 *   artifact (or errors). Nightlies/snapshots always come from Sonatype.
 *   ENTERPRISE_REPOSITORY=<url>   Custom Maven repository (must match the
 *                                 Maven structure). When set it is the ONLY
 *                                 repository consulted (no mirror/Central).
 *   RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED=false|0
 *                                 Skip the mirror and go straight to Maven
 *                                 Central.
 *
 * Output:
 *   <output>/React.xcframework/
 *   <output>/ReactNativeDependencies.xcframework/
 *   <output>/hermes-engine.xcframework/
 *   <output>/artifacts.json        ← maps target names to xcframework paths
 */

const {
  defaultCacheDir,
  displayPath,
  makeLogger,
  sharedCacheDir,
} = require('./spm-utils');
const {execFileSync} = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const stream = require('node:stream');
const yargs = require('yargs');

const {log, warn, die} = makeLogger('download-spm-artifacts');

function parseArgs(argv /*: Array<string> */) /*: DownloadArgs */ {
  const parsed = yargs(argv)
    .version(false)
    .option('version', {
      alias: 'v',
      type: 'string',
      describe:
        'RN version. Defaults to version in package.json. Use "nightly" to resolve the latest nightly.',
    })
    .option('flavor', {
      type: 'string',
      default: 'debug',
      describe: 'debug or release',
    })
    .option('output', {
      alias: 'o',
      type: 'string',
      describe:
        'Where to write xcframeworks. Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/',
    })
    .option('core-tarball', {
      type: 'string',
      describe:
        'Local React core tarball to use instead of downloading (e.g. the prebuild output). Env fallback: RN_CORE_TARBALL_PATH.',
    })
    .option('headers-tarball', {
      type: 'string',
      describe:
        'Local ReactNativeHeaders tarball to use instead of downloading. Env fallback: RN_HEADERS_TARBALL_PATH.',
    })
    .option('deps-tarball', {
      type: 'string',
      describe:
        'Local ReactNativeDependencies tarball to use instead of downloading (e.g. the deps prebuild output). Carries the headers sidecar beside the binary, so this subsumes --deps-headers-tarball for a local build. Env fallback: RN_DEPS_TARBALL_PATH.',
    })
    .option('deps-headers-tarball', {
      type: 'string',
      describe:
        'Local ReactNativeDependenciesHeaders tarball. Normally unneeded — the sidecar ships inside the ReactNativeDependencies tarball. Env fallback: RN_DEPS_HEADERS_TARBALL_PATH.',
    })
    .usage(
      'Usage: $0 [options]\n\nDownloads React Native iOS xcframeworks from Maven.',
    )
    .help()
    .parseSync();

  return {
    version: parsed.version ?? null,
    flavor: parsed.flavor.toLowerCase(),
    output: parsed.output ?? null,
    coreTarball:
      parsed['core-tarball'] ?? process.env.RN_CORE_TARBALL_PATH ?? null,
    headersTarball:
      parsed['headers-tarball'] ?? process.env.RN_HEADERS_TARBALL_PATH ?? null,
    depsTarball:
      parsed['deps-tarball'] ?? process.env.RN_DEPS_TARBALL_PATH ?? null,
    depsHeadersTarball:
      parsed['deps-headers-tarball'] ??
      process.env.RN_DEPS_HEADERS_TARBALL_PATH ??
      null,
  };
}

const MAVEN_CENTRAL_REPOSITORY = 'https://repo1.maven.org/maven2';
const REACT_NATIVE_MAVEN_MIRROR_REPOSITORY =
  'https://repo.reactnative.dev/maven2';
const MAVEN_SNAPSHOT =
  'https://central.sonatype.com/repository/maven-snapshots';

/**
 * The mirror is ON unless RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED is
 * explicitly "false"/"0".
 */
function reactNativeMavenMirrorEnabled() /*: boolean */ {
  const value = process.env.RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED;
  if (value == null || value === '') {
    return true;
  }
  return value.toLowerCase() !== 'false' && value !== '0';
}

/**
 * Release repositories to try, in order.
 * When ENTERPRISE_REPOSITORY is set it is the ONLY repository consulted.  
 *
 * Otherwise the React Native Maven mirror is kept before Maven Central so
 * cached artifacts are tried first and Maven Central remains the fallback.
 */
function mavenRepositoryUrls() /*: Array<string> */ {
  const enterpriseRepository = process.env.ENTERPRISE_REPOSITORY?.trim();
  if (enterpriseRepository != null && enterpriseRepository !== '') {
    return [enterpriseRepository.replace(/\/+$/, '')];
  }
  return reactNativeMavenMirrorEnabled()
    ? [REACT_NATIVE_MAVEN_MIRROR_REPOSITORY, MAVEN_CENTRAL_REPOSITORY]
    : [MAVEN_CENTRAL_REPOSITORY];
}

function rnCoreReleaseUrls(
  version /*: string */,
  flavor /*: string */,
) /*: Array<string> */ {
  return mavenRepositoryUrls().map(
    repository =>
      `${repository}/com/facebook/react/react-native-artifacts/${version}/` +
      `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz`,
  );
}
function rnDepsReleaseUrls(
  version /*: string */,
  flavor /*: string */,
) /*: Array<string> */ {
  return mavenRepositoryUrls().map(
    repository =>
      `${repository}/com/facebook/react/react-native-artifacts/${version}/` +
      `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz`,
  );
}
function hermesReleaseUrls(
  version /*: string */,
  flavor /*: string */,
) /*: Array<string> */ {
  return mavenRepositoryUrls().map(
    repository =>
      `${repository}/com/facebook/hermes/hermes-ios/${version}/` +
      `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz`,
  );
}

/**
 * Resolves a Maven snapshot URL by fetching maven-metadata.xml and extracting
 * the latest timestamp+buildNumber. Mirrors computeNightlyTarballURL() in utils.js.
 *
 * @param {string} version    Base version without -SNAPSHOT suffix (e.g. "0.85.0")
 * @param {string} subGroup   com/facebook/<subGroup>
 * @param {string} coordinate Maven artifact coordinate (e.g. "react-native-artifacts")
 * @param {string} artifactName  Classifier part of the filename (e.g. "reactnative-core-debug.tar.gz")
 */
async function resolveSnapshotUrl(
  version /*: string */,
  subGroup /*: string */,
  coordinate /*: string */,
  artifactName /*: string */,
) /*: Promise<string> */ {
  const metadataUrl =
    `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` +
    `${version}-SNAPSHOT/maven-metadata.xml`;

  log(`  Fetching snapshot metadata: ${metadataUrl}`);
  const res = await fetch(metadataUrl);
  if (!res.ok) {
    throw new Error(
      `Failed to fetch snapshot metadata (${res.status}): ${metadataUrl}`,
    );
  }
  const xml = await res.text();

  const ts = (xml.match(/<timestamp>(.*?)<\/timestamp>/) ?? [])[1];
  const bn = (xml.match(/<buildNumber>(.*?)<\/buildNumber>/) ?? [])[1];
  if (!ts || !bn) {
    throw new Error(
      `Could not parse timestamp/buildNumber from ${metadataUrl}`,
    );
  }

  const fullVersion = `${version}-${ts}-${bn}`;
  return (
    `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` +
    `${version}-SNAPSHOT/${coordinate}-${fullVersion}-${artifactName}`
  );
}

async function rnCoreSnapshotUrl(
  version /*: string */,
  flavor /*: string */,
) /*: Promise<string> */ {
  return resolveSnapshotUrl(
    version,
    'react',
    'react-native-artifacts',
    `reactnative-core-${flavor}.tar.gz`,
  );
}
async function rnDepsSnapshotUrl(
  version /*: string */,
  flavor /*: string */,
) /*: Promise<string> */ {
  return resolveSnapshotUrl(
    version,
    'react',
    'react-native-artifacts',
    `reactnative-dependencies-${flavor}.tar.gz`,
  );
}
async function hermesSnapshotUrl(
  version /*: string */,
  flavor /*: string */,
) /*: Promise<string> */ {
  return resolveSnapshotUrl(
    version,
    'hermes',
    'hermes-ios',
    `hermes-ios-${flavor}.tar.gz`,
  );
}

async function resolveNightlyVersion(
  npmPackage /*: string */,
) /*: Promise<string> */ {
  log(`  Resolving nightly version from npm: ${npmPackage}`);

  const res = await fetch(`https://registry.npmjs.org/${npmPackage}/nightly`);
  if (!res.ok) {
    throw new Error(`npm lookup failed for ${npmPackage}: ${res.status}`);
  }
  const ver = (await res.json())?.version;
  assertSafeVersion(ver, `${npmPackage}/nightly`);
  log(`  Resolved nightly: ${ver}`);
  return ver;
}

// A version string flows into Maven URLs and local tarball filenames (which are
// then passed to tar/cp via execFileSync). Constrain it to a safe charset so a
// malformed/hostile registry response can't produce a surprising path or a
// confusing 404 — and so static analysis sees an explicit sanitizer.
function assertSafeVersion(
  ver /*: unknown */,
  source /*: string */,
) /*: void */ {
  if (typeof ver !== 'string' || !/^[A-Za-z0-9._-]+$/.test(ver)) {
    throw new Error(
      `npm response for ${source} has no usable "version" field (got: ${String(ver)})`,
    );
  }
}

/**
 * Returns the cache-slot key for a given raw version label.
 *
 * Stable versions ('0.80.0', '0.81.0', …) become their own slot.
 * Dev / nightly labels ('1000.0.0', 'nightly') resolve to the current
 * nightly version (e.g. '0.85.0-nightly-20260515-abc') so each published
 * nightly is its own slot — a new nightly invalidates automatically
 * instead of sticking on a stale `1000.0.0` cache forever.
 *
 * If the npm registry lookup fails (offline, transient error), falls back
 * to the raw label so a previously-cached slot under that label can still
 * be used. A subsequent download attempt would surface the real error.
 */
async function resolveCacheSlotVersion(
  rawVersion /*: string */,
) /*: Promise<string> */ {
  if (rawVersion !== '1000.0.0' && rawVersion !== 'nightly') {
    return rawVersion;
  }
  try {
    return await resolveNightlyVersion('react-native');
  } catch {
    return rawVersion;
  }
}

async function resolveLatestV1Version() /*: Promise<string> */ {
  log('  Resolving latest-v1 Hermes from npm...');
  // $FlowFixMe[incompatible-call] global fetch not in Flow stubs
  const res = await fetch(
    'https://registry.npmjs.org/hermes-compiler/latest-v1',
  );
  if (!res.ok) {
    throw new Error(`npm lookup failed: ${res.status}`);
  }
  const ver = (await res.json())?.version;
  assertSafeVersion(ver, 'hermes-compiler/latest-v1');
  log(`  Resolved latest-v1: ${ver}`);
  return ver;
}

async function exists(url /*: string */) /*: Promise<boolean> */ {
  try {
    // $FlowFixMe[incompatible-call] global fetch not in Flow stubs
    const res = await fetch(url, {method: 'HEAD'});
    return res.status === 200;
  } catch {
    return false;
  }
}

/**
 * Probes candidate URLs in order and returns the first one that exists
 * (HTTP 200 on HEAD), or null when none do.
 */
async function firstExistingUrl(
  candidates /*: Array<string> */,
) /*: Promise<string | null> */ {
  for (const url of candidates) {
    if (await exists(url)) {
      return url;
    }
  }
  return null;
}

/**
 * Returns {url, version} for the React Native core xcframework tarball.
 * Resolution order:
 *   1. Stable release on the RN Maven mirror
 *   2. Stable release on Maven Central
 *   3. Snapshot build on Sonatype
 */
async function resolveRNCoreArtifact(
  version /*: string */,
  flavor /*: string */,
  localTarball /*: ?string */,
) /*: Promise<ResolvedArtifact> */ {
  // Local-tarball override (--core-tarball / RN_CORE_TARBALL_PATH): use a
  // locally built core tarball (e.g. the prebuild's output) instead of
  // downloading. processArtifact() treats an existing local path as "already
  // downloaded" and always re-extracts it. NOTE: distinct from CocoaPods'
  // RCT_TESTONLY_RNCORE_TARBALL_PATH — that one belongs to pod install.
  if (localTarball != null && localTarball !== '') {
    if (!fs.existsSync(localTarball)) {
      throw new Error(
        `core tarball override is set to ${localTarball} but the file does not exist`,
      );
    }
    log(`  Using LOCAL core tarball: ${localTarball}`);
    return {url: localTarball, version: `${version}-local`};
  }
  const releaseUrl = await firstExistingUrl(rnCoreReleaseUrls(version, flavor));
  if (releaseUrl != null) {
    log(`  Using stable release: ${releaseUrl}`);
    return {url: releaseUrl, version};
  }
  log(`  Release not found in any Maven repository, trying snapshot...`);
  const snapshotUrl = await rnCoreSnapshotUrl(version, flavor);
  return {url: snapshotUrl, version};
}

/**
 * Returns {url, version} for ReactNativeDependencies.
 * Respects RN_DEP_VERSION env var.
 */
async function resolveRNDepsArtifact(
  rnVersion /*: string */,
  flavor /*: string */,
  localTarball /*: ?string */,
) /*: Promise<ResolvedArtifact> */ {
  // Local-tarball override (--deps-tarball / RN_DEPS_TARBALL_PATH): use a
  // locally built deps tarball (the deps prebuild output) instead of
  // downloading. It carries ReactNativeDependenciesHeaders.xcframework beside
  // the binary, so the existing companion-staging path (COMPANION_XCFRAMEWORKS
  // 'rndeps' -> ReactNativeDependenciesHeaders) supplies the sidecar too — no
  // separate --deps-headers-tarball needed for a local build.
  if (localTarball != null && localTarball !== '') {
    if (!fs.existsSync(localTarball)) {
      throw new Error(
        `deps tarball override is set to ${localTarball} but the file does not exist`,
      );
    }
    log(`  Using LOCAL deps tarball: ${localTarball}`);
    return {url: localTarball, version: `${rnVersion}-local`};
  }
  let version = process.env.RN_DEP_VERSION ?? rnVersion;
  if (version === 'nightly') {
    version = await resolveNightlyVersion('react-native');
  }

  const releaseUrl = await firstExistingUrl(rnDepsReleaseUrls(version, flavor));
  if (releaseUrl != null) {
    log(`  Using stable release: ${releaseUrl}`);
    return {url: releaseUrl, version};
  }
  log(`  Release not found in any Maven repository, trying snapshot...`);
  const snapshotUrl = await rnDepsSnapshotUrl(version, flavor);
  return {url: snapshotUrl, version};
}

/**
 * Resolves the `hermes-compiler` npm package's version from THIS project's own
 * node_modules — the same lookup react-native-xcode.sh falls back to for
 * SwiftPM builds to find the hermesc binary that compiles the JS bundle.
 * Returns null when the package isn't resolvable (e.g.
 * USE_HERMES=false apps that never installed it) so the caller can fall back
 * to the npm dist-tag lookup.
 */
function resolveLocalHermesCompilerVersion(
  rnRoot /*: string */,
) /*: string | null */ {
  try {
    const pkgPath = require.resolve('hermes-compiler/package.json', {
      paths: [rnRoot],
    });
    // $FlowFixMe[incompatible-type] JSON.parse returns any
    const pkg /*: {version: string} */ = JSON.parse(
      fs.readFileSync(pkgPath, 'utf8'),
    );
    assertSafeVersion(pkg.version, 'local hermes-compiler/package.json');
    return pkg.version;
  } catch (error) {
    // A MODULE_NOT_FOUND resolution failure is the expected case (e.g.
    // USE_HERMES=false apps that never installed hermes-compiler) — fall back
    // silently. Any other failure means hermes-compiler IS installed but its
    // package.json is unreadable/malformed or carries an unsafe version; warn
    // loudly rather than silently regressing to the live latest-v1 dist-tag,
    // which would re-introduce the version-skew crash this resolves (#57917).
    if (error.code !== 'MODULE_NOT_FOUND') {
      log(
        `  WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`,
      );
    }
    return null;
  }
}

/**
 * Returns {url, version} for Hermes. Hermes uses its own version space
 * decoupled from React Native's nightly cadence — RN's `hermes-compiler`
 * npm package publishes a `latest-v1` dist-tag that always resolves to a
 * binary that's been built and uploaded to Maven.
 *
 *   HERMES_VERSION unset       → version pinned by the locally installed
 *                                hermes-compiler package (node_modules).
 *                                This is the SAME source
 *                                react-native-xcode.sh resolves hermesc
 *                                from, so the downloaded VM and
 *                                the hermesc that compiles the JS bundle
 *                                always agree — a mismatched pair crashes at
 *                                launch with "Wrong bytecode version" (#57917).
 *                                Falls back to the 'latest-v1' npm dist-tag
 *                                (RN's CocoaPods prebuild default; see
 *                                scripts/ios-prebuild/hermes.js) only when
 *                                hermes-compiler isn't locally resolvable.
 *   HERMES_VERSION=latest-v1   → 'latest-v1' dist-tag (explicit)
 *   HERMES_VERSION=nightly     → hermes-compiler@nightly dist-tag
 *   HERMES_VERSION=<literal>   → use that version verbatim
 *
 * Note: rnVersion / rawVersion are intentionally not consulted. There is no
 * guarantee a hermes-ios artifact exists for any given RN nightly hash —
 * tying them together produces 404s like #(repro case from spikes/MyApp).
 */
async function resolveHermesArtifact(
  rnVersion /*: string */,
  flavor /*: string */,
  rawVersion /*: string | null */,
  rnRoot /*: string */,
) /*: Promise<ResolvedArtifact> */ {
  let version = process.env.HERMES_VERSION;

  if (version == null) {
    const localVersion = resolveLocalHermesCompilerVersion(rnRoot);
    if (localVersion != null) {
      log(`  Using locally pinned hermes-compiler: ${localVersion}`);
    }
    version = localVersion ?? 'latest-v1';
  }

  if (version === 'nightly') {
    version = await resolveNightlyVersion('hermes-compiler');
  } else if (version === 'latest-v1') {
    version = await resolveLatestV1Version();
  }

  const releaseUrl = await firstExistingUrl(hermesReleaseUrls(version, flavor));
  if (releaseUrl != null) {
    log(`  Using stable release: ${releaseUrl}`);
    return {url: releaseUrl, version};
  }
  log(`  Release not found in any Maven repository, trying snapshot...`);
  const snapshotUrl = await hermesSnapshotUrl(version, flavor);
  return {url: snapshotUrl, version};
}

function formatBytes(bytes /*: number */) /*: string */ {
  if (bytes < 1024 * 1024) {
    return `${(bytes / 1024).toFixed(1)} KB`;
  }
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function formatSpeed(bytesPerSec /*: number */) /*: string */ {
  if (bytesPerSec < 1024 * 1024) {
    return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
  }
  return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
}

/**
 * Creates a multi-line progress display that keeps N lines pinned at the
 * bottom of the terminal. Each line is prefixed and truncated to the current
 * terminal width — without truncation, a long line (e.g. a FAILED message
 * carrying a URL) wraps to a second row and `\x1b[2K` only clears the first,
 * leaving stray fragments after the next update.
 */
function createProgressDisplay(
  lineCount /*: number */,
  prefix /*: string */ = '',
) /*: {update: (index: number, text: string) => void} */ {
  let initialized = false;

  function truncateToWidth(s /*: string */) /*: string */ {
    // $FlowFixMe[prop-missing] columns lives on tty$WriteStream not stream$Writable
    const cols = process.stdout.columns ?? 120;
    const budget = Math.max(10, cols - 1);
    let out = '';
    let visLen = 0;
    let i = 0;
    while (i < s.length) {
      if (s[i] === '\x1b' && s[i + 1] === '[') {
        // CSI escape: forward through the final letter without counting.
        let j = i + 2;
        while (j < s.length && !/[a-zA-Z]/.test(s[j])) j++;
        out += s.slice(i, j + 1);
        i = j + 1;
      } else {
        if (visLen >= budget - 1) return out + '…\x1b[0m';
        out += s[i];
        visLen++;
        i++;
      }
    }
    return out;
  }

  function update(index /*: number */, text /*: string */) {
    if (!initialized) {
      for (let i = 0; i < lineCount; i++) {
        process.stdout.write('\n');
      }
      initialized = true;
    }
    const moveUp = lineCount - index;
    const line = truncateToWidth(prefix + text);
    process.stdout.write(`\x1b[${moveUp}A\x1b[2K\r${line}\x1b[${moveUp}B\r`);
  }

  return {update};
}

/*::
type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void;
*/

async function download(
  url /*: string */,
  destPath /*: string */,
  onProgress /*:: ?: ProgressCallback */,
) /*: Promise<void> */ {
  if (fs.existsSync(destPath)) {
    log(`  Already cached: ${path.basename(destPath)}`);
    return;
  }

  const tmpPath = destPath + '.download';
  try {
    // $FlowFixMe[incompatible-call] global fetch not in Flow stubs
    const res = await fetch(url);
    if (!res.ok) {
      throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
    }

    const totalBytes = parseInt(res.headers.get('content-length') ?? '0', 10);
    let downloadedBytes = 0;
    let lastPrintTime = Date.now();
    let lastPrintBytes = 0;
    const startTime = Date.now();

    const fileStream = fs.createWriteStream(tmpPath);

    const reportProgress = (final /*: boolean */ = false) => {
      const now = Date.now();
      const elapsed = (now - startTime) / 1000;
      const intervalMs = now - lastPrintTime;
      const intervalBytes = downloadedBytes - lastPrintBytes;
      const speed = intervalMs > 0 ? (intervalBytes / intervalMs) * 1000 : 0;

      if (onProgress) {
        onProgress(
          path.basename(destPath),
          downloadedBytes,
          totalBytes,
          speed,
          final,
          elapsed,
        );
      } else {
        // Fallback: single-line progress (used when not in parallel mode)
        let line = `  ${formatBytes(downloadedBytes)}`;
        if (totalBytes > 0) {
          const pct = ((downloadedBytes / totalBytes) * 100).toFixed(1);
          line += ` / ${formatBytes(totalBytes)} (${pct}%)`;
        }
        line += ` @ ${formatSpeed(speed)}`;

        if (final) {
          const totalMb = formatBytes(downloadedBytes);
          const totalSec = elapsed.toFixed(1);
          const avgSpeed =
            elapsed > 0 ? formatSpeed(downloadedBytes / elapsed) : '';
          process.stdout.write(
            `\r  Done: ${totalMb} in ${totalSec}s (avg ${avgSpeed})          \n`,
          );
        } else {
          process.stdout.write(`\r${line}    `);
        }
      }

      if (!final) {
        lastPrintTime = now;
        lastPrintBytes = downloadedBytes;
      }
    };

    if (res.body) {
      // fetch() returns a Web ReadableStream, not a Node.js Readable.
      // Convert it so we can pipe to a file stream and track progress.
      // $FlowFixMe[prop-missing] stream.Readable.fromWeb not in Flow stubs
      const nodeReadable = stream.Readable.fromWeb(res.body);

      await new Promise((resolve, reject) => {
        let progressInterval;
        try {
          progressInterval = setInterval(() => reportProgress(), 500);

          nodeReadable
            .on('data', chunk => {
              downloadedBytes += chunk.length;
            })
            .on('error', err => {
              clearInterval(progressInterval);
              reject(err);
            })
            .pipe(fileStream)
            .on('finish', () => {
              clearInterval(progressInterval);
              reportProgress(true);
              resolve();
            })
            .on('error', err => {
              clearInterval(progressInterval);
              reject(err);
            });
        } catch (err) {
          if (progressInterval != null) clearInterval(progressInterval);
          reject(err);
        }
      });
    } else {
      const buf = await res.arrayBuffer();
      downloadedBytes = buf.byteLength;
      fs.writeFileSync(tmpPath, Buffer.from(buf));
      reportProgress(true);
    }

    fs.renameSync(tmpPath, destPath);
  } catch (err) {
    // Clean up partial .download temp file on failure
    try {
      fs.unlinkSync(tmpPath);
    } catch {
      // temp file may not exist yet
    }
    throw err;
  }
}

/**
 * Extracts a .tar.gz and returns the path to the first .xcframework found.
 */
function extractXCFramework(
  tarPath /*: string */,
  extractDir /*: string */,
) /*: string */ {
  fs.mkdirSync(extractDir, {recursive: true});
  log(`  Extracting ${path.basename(tarPath)}...`);
  execFileSync('tar', ['-xzf', tarPath, '-C', extractDir], {stdio: 'pipe'});

  const found = findFirst(extractDir, name => name.endsWith('.xcframework'), 8);
  if (found == null) {
    throw new Error(`No .xcframework found after extracting ${tarPath}`);
  }
  log(`  Found: ${path.relative(extractDir, found)}`);
  return found;
}

function findFirst(
  dir /*: string */,
  predicate /*: (name: string) => boolean */,
  depth /*: number */,
) /*: string | null */ {
  if (depth <= 0 || !fs.existsSync(dir)) {
    return null;
  }
  for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
    // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here
    const full /*: string */ = path.join(dir, entry.name);
    // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here
    if (predicate(entry.name)) {
      return full;
    }
    if (entry.isDirectory()) {
      const hit = findFirst(full, predicate, depth - 1);
      if (hit != null) {
        return hit;
      }
    }
  }
  return null;
}

/**
 * The hermes-ios tarball ships its public C++ API headers in
 * `destroot/include/hermes` alongside the framework — which extractXCFramework
 * discards (it keeps only the .xcframework). Stage the `hermes/` namespace into
 * `<outputDir>/hermes-headers/hermes` so headers-compose can fold it into
 * ReactNativeHeaders (making `<hermes/hermes.h>` resolve for any RN-linking
 * target). Only `hermes/` is staged — `jsi/` is already vended elsewhere.
 * Best-effort: a tarball without these headers just leaves hermes unavailable.
 */
function stageHermesHeaders(
  extractDir /*: string */,
  outputDir /*: string */,
) /*: void */ {
  let includeDir = path.join(extractDir, 'destroot', 'include');
  if (!fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))) {
    // Fall back to locating the include dir wherever it landed in the tarball.
    const hit = findFirst(extractDir, name => name === 'include', 8);
    if (hit != null) {
      includeDir = hit;
    }
  }
  const src = path.join(includeDir, 'hermes');
  if (!fs.existsSync(path.join(src, 'hermes.h'))) {
    log('  Hermes public headers not found in tarball — skipping header stage');
    return;
  }
  const destRoot = path.join(outputDir, 'hermes-headers');
  const dest = path.join(destRoot, 'hermes');
  fs.rmSync(dest, {recursive: true, force: true});
  fs.mkdirSync(destRoot, {recursive: true});
  execFileSync('/bin/cp', ['-R', src, dest], {stdio: 'pipe'});
  log('  Staged Hermes public headers → hermes-headers/hermes');
}

const DEPS_HEADERS_NAME = 'ReactNativeDependenciesHeaders';

// Headers-only companion xcframeworks that ship INSIDE another artifact's
// tarball: the ReactCore tarball carries ReactNativeHeaders.xcframework
// beside React.xcframework, and the ReactNativeDependencies tarball carries
// the ReactNativeDependenciesHeaders sidecar beside the binary (the binary is
// framework-type, so only the LIBRARY-type sidecar can serve the deps
// namespaces — folly/glog/boost/... — to SwiftPM; ReactNativeHeaders is
// pure-RN). extractXCFramework keeps only the first xcframework, so
// companions are staged separately into their own
// `<outputDir>/<name>.xcframework`.
const COMPANION_XCFRAMEWORKS /*: {[label: string]: string} */ = {
  'react-core': 'ReactNativeHeaders',
  rndeps: DEPS_HEADERS_NAME,
};

/**
 * Stages a companion xcframework out of an extract dir. Returns true when
 * staged; false when the tarball predates the companion.
 */
function stageCompanionXcframework(
  extractDir /*: string */,
  outputDir /*: string */,
  name /*: string */,
) /*: boolean */ {
  const src = findFirst(extractDir, n => n === `${name}.xcframework`, 8);
  if (src == null) {
    return false;
  }
  const dest = path.join(outputDir, `${name}.xcframework`);
  fs.rmSync(dest, {recursive: true, force: true});
  fs.renameSync(src, dest);
  log(`  Staged companion ${name}.xcframework`);
  return true;
}

/**
 * Self-heal: stage a companion xcframework into an already-extracted slot
 * (the fast path skips extraction, so the companion was never staged).
 * Prefers a CACHED tarball (no network); downloads only as a last resort.
 */
async function ensureCompanionStaged(
  url /*: string */,
  downloadDir /*: string */,
  sharedTarballName /*: ?string */,
  outputDir /*: string */,
  name /*: string */,
) /*: Promise<void> */ {
  const candidates = [
    !/^https?:\/\//.test(url) ? url : null, // local-tarball override
    path.join(downloadDir, url.split('/').pop() ?? ''),
    sharedTarballName != null
      ? path.join(sharedCacheDir(), sharedTarballName)
      : null,
  ].filter(Boolean);
  let tarPath /*: ?string */ = candidates.find(
    p => p != null && fs.existsSync(p),
  );
  if (tarPath == null) {
    if (!/^https?:\/\//.test(url)) {
      return; // local override missing — nothing to recover from
    }
    const localPath = path.join(
      downloadDir,
      url.split('/').pop() ?? 'artifact.tar.gz',
    );
    fs.mkdirSync(downloadDir, {recursive: true});
    await download(url, localPath);
    tarPath = localPath;
  }
  const tmp = path.join(outputDir, `.companion-tmp-${name}`);
  fs.rmSync(tmp, {recursive: true, force: true});
  fs.mkdirSync(tmp, {recursive: true});
  try {
    execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'});
    stageCompanionXcframework(tmp, outputDir, name);
  } finally {
    fs.rmSync(tmp, {recursive: true, force: true});
  }
}

/**
 * Self-heal: stage Hermes headers into an already-extracted slot (the fast
 * path skips extraction, so the headers were never staged). Prefers a CACHED
 * hermes tarball (no network); downloads only as a last resort so the slot
 * can't get stuck "incomplete" forever. No-op only when the headers can't be
 * obtained at all (e.g. a missing local-tarball override).
 */
async function ensureHermesHeadersStaged(
  url /*: string */,
  downloadDir /*: string */,
  sharedTarballName /*: ?string */,
  outputDir /*: string */,
) /*: Promise<void> */ {
  const candidates = [
    !/^https?:\/\//.test(url) ? url : null, // local-tarball override
    path.join(downloadDir, url.split('/').pop() ?? ''),
    sharedTarballName != null
      ? path.join(sharedCacheDir(), sharedTarballName)
      : null,
  ].filter(Boolean);
  let tarPath /*: ?string */ = candidates.find(
    p => p != null && fs.existsSync(p),
  );
  if (tarPath == null) {
    if (!/^https?:\/\//.test(url)) {
      return; // local override missing — nothing to recover from
    }
    const localPath = path.join(
      downloadDir,
      url.split('/').pop() ?? 'hermes.tar.gz',
    );
    fs.mkdirSync(downloadDir, {recursive: true});
    await download(url, localPath);
    tarPath = localPath;
  }
  const tmp = path.join(outputDir, '.hermes-hdr-tmp');
  fs.rmSync(tmp, {recursive: true, force: true});
  fs.mkdirSync(tmp, {recursive: true});
  try {
    execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'});
    stageHermesHeaders(tmp, outputDir);
  } finally {
    fs.rmSync(tmp, {recursive: true, force: true});
  }
}

/**
 * Downloads a tarball, extracts the xcframework, and places it directly in
 * the output directory as <xcframeworkName>.xcframework/.
 *
 * SPM binaryTarget(path:) accepts a bare .xcframework directory — no zip or
 * checksum needed for local path-based targets.
 *
 * @param {string} label           Internal label (used for log messages)
 * @param {string} xcframeworkName The SPM target name (e.g. "React", "hermes-engine")
 * @param resolvedArtifact         {url, version} from resolve*Artifact()
 * @param {string} downloadDir     Where to cache downloaded tarballs
 * @param {string} outputDir       Where to place the final <name>.xcframework directory
 * @param sharedTarballName        Filename in the flat shared cache to reuse/populate
 *                                 (matches CocoaPods' convention), or null to not share.
 */
async function processArtifact(
  label /*: string */,
  xcframeworkName /*: string */,
  resolvedArtifact /*: ResolvedArtifact */,
  downloadDir /*: string */,
  outputDir /*: string */,
  onProgress /*:: ?: ProgressCallback */,
  sharedTarballName /*:: ?: ?string */,
) /*: Promise<ProcessResult> */ {
  const {url, version} = resolvedArtifact;

  const destXcfwPath = path.join(outputDir, `${xcframeworkName}.xcframework`);
  // Local-tarball override: `url` is an existing local file. Always
  // re-extract (a changed local tarball must win over a previous extraction)
  // and never touch the shared cache.
  const isLocalTarball = !/^https?:\/\//.test(url) && fs.existsSync(url);
  if (isLocalTarball && fs.existsSync(destXcfwPath)) {
    fs.rmSync(destXcfwPath, {recursive: true, force: true});
  }
  if (fs.existsSync(destXcfwPath)) {
    if (onProgress) {
      onProgress(xcframeworkName, 0, 0, 0, true, 0);
    } else {
      log(`  Already extracted: ${xcframeworkName}.xcframework`);
    }
    // The xcframework is cached, but a slot from older tooling won't have the
    // Hermes headers staged. Backfill them from a cached tarball (no network).
    if (
      label === 'hermes' &&
      !fs.existsSync(path.join(outputDir, 'hermes-headers', 'hermes'))
    ) {
      try {
        await ensureHermesHeadersStaged(
          url,
          downloadDir,
          sharedTarballName,
          outputDir,
        );
      } catch (e) {
        log(`  Hermes header backfill failed (${e.message}) — continuing`);
      }
    }
    // Same for the headers-only companions (ReactNativeHeaders from the core
    // tarball, the ReactNativeDependenciesHeaders sidecar from the deps
    // tarball): backfill into slots extracted by older tooling from the
    // cached tarball.
    const companion = COMPANION_XCFRAMEWORKS[label];
    if (
      companion != null &&
      !fs.existsSync(path.join(outputDir, `${companion}.xcframework`))
    ) {
      try {
        await ensureCompanionStaged(
          url,
          downloadDir,
          sharedTarballName,
          outputDir,
          companion,
        );
      } catch (e) {
        log(`  ${companion} backfill failed (${e.message}) — continuing`);
      }
    }
    return {label, version, xcframeworkPath: destXcfwPath, url};
  }

  // Tarball acquisition: prefer the flat shared cache (~/Library/Caches/
  // ReactNative/<name>) that CocoaPods also populates, so SPM and `pod install`
  // reuse the same download. RCT_SKIP_CACHES=1 bypasses it (mirrors CocoaPods).
  const skipCaches = process.env.RCT_SKIP_CACHES === '1';
  const sharedPath =
    !skipCaches && sharedTarballName != null
      ? path.join(sharedCacheDir(), sharedTarballName)
      : null;

  const downloadAndCache = async () /*: Promise<string> */ => {
    const tarName = url.split('/').pop() ?? '';
    const localPath = path.join(downloadDir, tarName);
    await download(
      url,
      localPath,
      onProgress
        ? (name, downloaded, total, speed, done, elapsed) =>
            onProgress(xcframeworkName, downloaded, total, speed, done, elapsed)
        : undefined,
    );
    // Best-effort: save into the flat shared cache for future SPM/CocoaPods runs.
    if (sharedPath != null) {
      try {
        fs.mkdirSync(sharedCacheDir(), {recursive: true});
        fs.copyFileSync(localPath, sharedPath);
      } catch {
        // ignore shared-cache write failures
      }
    }
    return localPath;
  };

  let tarPath /*: string */ = '';
  let fromShared = false;
  if (isLocalTarball) {
    tarPath = url;
    if (onProgress) {
      onProgress(xcframeworkName, 0, 0, 0, true, 0);
    } else {
      log(`  Using local tarball: ${url}`);
    }
  } else if (sharedPath != null && fs.existsSync(sharedPath)) {
    // Shared cache hit — skip the download entirely.
    tarPath = sharedPath;
    fromShared = true;
    if (onProgress) {
      onProgress(xcframeworkName, 0, 0, 0, true, 0);
    } else {
      log(`  Shared cache hit: ${path.basename(sharedPath)}`);
    }
  } else {
    tarPath = await downloadAndCache();
  }

  // Extract to a temp dir, rename to the expected name, then move into outputDir
  if (onProgress) {
    onProgress(xcframeworkName, 0, 0, 0, false, 0);
  }
  const tmpExtractDir = path.join(outputDir, '.extract-tmp', label);
  let xcfwPath /*: string */ = '';
  try {
    xcfwPath = extractXCFramework(tarPath, tmpExtractDir);
  } catch (e) {
    // A poisoned shared tarball must not permanently break SPM: drop it and
    // re-download to the local dir once.
    if (fromShared) {
      try {
        fs.rmSync(tarPath, {force: true});
      } catch {}
      tarPath = await downloadAndCache();
      xcfwPath = extractXCFramework(tarPath, tmpExtractDir);
    } else {
      throw e;
    }
  }

  const actualBasename = path.basename(xcfwPath);
  const expectedBasename = `${xcframeworkName}.xcframework`;
  if (actualBasename !== expectedBasename) {
    const renamed = path.join(tmpExtractDir, expectedBasename);
    fs.renameSync(xcfwPath, renamed);
    fs.renameSync(renamed, destXcfwPath);
  } else {
    fs.renameSync(xcfwPath, destXcfwPath);
  }

  // Hermes ships its public headers in the same tarball; stage them next to
  // the xcframeworks so headers-compose can fold `hermes/` into
  // ReactNativeHeaders. (Other artifacts have no such headers — no-op.)
  if (label === 'hermes') {
    try {
      stageHermesHeaders(tmpExtractDir, outputDir);
    } catch (e) {
      log(`  Hermes header staging failed (${e.message}) — continuing`);
    }
  }

  // Headers-only companions ship inside the same tarball (ReactNativeHeaders
  // in the core tarball, the ReactNativeDependenciesHeaders sidecar in the
  // deps tarball) — stage them as their own artifacts.
  const companionName = COMPANION_XCFRAMEWORKS[label];
  if (companionName != null) {
    if (!stageCompanionXcframework(tmpExtractDir, outputDir, companionName)) {
      log(
        `  ${companionName}.xcframework not present in the ${label} tarball (pre-companion artifact)`,
      );
    }
  }

  fs.rmSync(tmpExtractDir, {recursive: true, force: true});

  return {label, version, xcframeworkPath: destXcfwPath, url};
}

async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
  const args = parseArgs(argv ?? process.argv.slice(2));
  const rnRoot = path.resolve(__dirname, '../..');
  const flavor = args.flavor;

  // Resolve base RN version
  // rawVersion preserves the original --version arg (e.g. 'nightly') before resolution.
  // It is passed to Hermes resolution so it can independently resolve its nightly.
  let rawVersion = args.version;
  let rnVersion = args.version;
  if (rnVersion == null) {
    // $FlowFixMe[incompatible-type] JSON.parse returns any
    const rnPkg /*: {version: string} */ = JSON.parse(
      fs.readFileSync(path.join(rnRoot, 'package.json'), 'utf8'),
    );
    rnVersion = rnPkg.version;
  }
  if (rnVersion === '1000.0.0') {
    log('Detected dev version (1000.0.0), resolving as nightly...');
    rawVersion = 'nightly';
  }
  if (rnVersion === 'nightly' || rnVersion === '1000.0.0') {
    rnVersion = await resolveNightlyVersion('react-native');
  }
  if (rnVersion == null) {
    die('Could not determine RN version');
  }
  // Re-bind to const so Flow keeps the non-null narrowing across the closures
  // below (let-bound vars are widened across function boundaries).
  const resolvedRnVersion /*: string */ = rnVersion;

  // Cache key: stable versions slot under their own number. Dev / nightly
  // labels use the resolved nightly hash (e.g. "0.85.0-nightly-20260515-abc")
  // so each published nightly is its own slot — picks up new specs and fixes
  // automatically instead of sticking on a stale "1000.0.0" cache forever.
  const cacheVersionKey =
    rawVersion === 'nightly' || rawVersion === '1000.0.0' || rawVersion == null
      ? resolvedRnVersion
      : rawVersion;
  const outputDir =
    args.output != null
      ? path.resolve(args.output)
      : defaultCacheDir(cacheVersionKey, flavor);
  // Tarballs are cached in a .downloads/ subdirectory to keep them separate
  // from the extracted .xcframework directories.
  const downloadDir = path.join(outputDir, '.downloads');

  fs.mkdirSync(outputDir, {recursive: true});
  fs.mkdirSync(downloadDir, {recursive: true});

  log(`RN version : ${resolvedRnVersion}`);
  log(`Flavor     : ${flavor}`);
  log(`Output     : ${displayPath(outputDir)}`);
  log('');

  // Download all three artifacts in parallel for faster setup
  log('Downloading artifacts in parallel...');

  // `sharedName` builds the flat shared-cache filename in the canonical
  // ~/Library/Caches/ReactNative/ dir, matching the names other RN tooling uses
  // (CocoaPods' rncore.rb / rndependencies.rb for core+deps, and the hermes
  // prebuilt tarball name) so SPM and `pod install` reuse the same downloads.
  // `v` is each artifact's resolved version (RN version for core/deps, the
  // hermes-ios version for hermes).
  const artifactSpecs = [
    {
      label: 'react-core',
      name: 'React',
      resolve: () =>
        resolveRNCoreArtifact(resolvedRnVersion, flavor, args.coreTarball),
      // Local overrides skip the shared cache (test artifacts must not
      // poison the canonical downloads).
      sharedName:
        args.coreTarball != null
          ? null
          : (v /*: string */) => `reactnative-core-${v}-${flavor}.tar.gz`,
    },
    {
      label: 'rndeps',
      name: 'ReactNativeDependencies',
      resolve: () =>
        resolveRNDepsArtifact(resolvedRnVersion, flavor, args.depsTarball),
      // Local override skips the shared cache (as with core) so a local deps
      // build can't poison the canonical downloads.
      sharedName:
        args.depsTarball != null
          ? null
          : (v /*: string */) =>
              `reactnative-dependencies-${v}-${flavor}.tar.gz`,
    },
    {
      label: 'hermes',
      name: 'hermes-engine',
      resolve: () =>
        resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot),
      sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`,
    },
  ];

  // ReactNativeHeaders is normally staged straight out of the ReactCore
  // tarball (it ships beside React.xcframework). The override exists for
  // testing a standalone headers tarball (e.g. the prebuild output at
  // .build/output/xcframeworks/<flavor>/ReactNativeHeaders.xcframework.tar.gz,
  // published on Maven under classifier reactnative-headers-<flavor>).
  const headersTarball = args.headersTarball;
  if (headersTarball != null && headersTarball !== '') {
    if (!fs.existsSync(headersTarball)) {
      die(
        `headers tarball override is set to ${headersTarball} but the file does not exist`,
      );
    }
    artifactSpecs.push({
      label: 'rnheaders',
      name: 'ReactNativeHeaders',
      resolve: () =>
        Promise.resolve({
          url: headersTarball,
          version: `${resolvedRnVersion}-local`,
        }),
      sharedName: null,
    });
  }

  // ReactNativeDependenciesHeaders: normally staged straight out of the deps
  // tarball (the sidecar ships inside it). The override exists for testing a
  // standalone sidecar tarball (classifier
  // reactnative-dependencies-headers-<flavor>).
  const depsHeadersTarball = args.depsHeadersTarball;
  if (depsHeadersTarball != null && depsHeadersTarball !== '') {
    if (!fs.existsSync(depsHeadersTarball)) {
      die(
        `deps-headers tarball override is set to ${depsHeadersTarball} but the file does not exist`,
      );
    }
    artifactSpecs.push({
      label: 'rndeps-headers',
      name: DEPS_HEADERS_NAME,
      resolve: () =>
        Promise.resolve({
          url: depsHeadersTarball,
          version: `${resolvedRnVersion}-local`,
        }),
      sharedName: null,
    });
  }

  const progress = createProgressDisplay(
    artifactSpecs.length,
    '\x1b[32m[download-spm-artifacts]\x1b[0m ',
  );

  const makeCallback = (index /*: number */) /*: ProgressCallback */ =>
    (name, downloaded, total, speed, done, elapsed) => {
      if (done && downloaded === 0 && total === 0) {
        progress.update(index, `  ${name}: already cached`);
      } else if (done) {
        const avg = elapsed > 0 ? formatSpeed(downloaded / elapsed) : '';
        progress.update(
          index,
          `  ${name}: done ${formatBytes(downloaded)} in ${elapsed.toFixed(1)}s (${avg})`,
        );
      } else if (total > 0) {
        const pct = ((downloaded / total) * 100).toFixed(1);
        progress.update(
          index,
          `  ${name}: ${formatBytes(downloaded)} / ${formatBytes(total)} (${pct}%) @ ${formatSpeed(speed)}`,
        );
      } else {
        progress.update(index, `  ${name}: extracting...`);
      }
    };

  const results /*: Array<ArtifactResultEntry> */ = await Promise.all(
    artifactSpecs.map(async (spec, index) => {
      try {
        const artifact = await spec.resolve();
        progress.update(index, `  ${spec.name}: resolving...`);
        const sharedTarballName =
          spec.sharedName != null ? spec.sharedName(artifact.version) : null;
        const r = await processArtifact(
          spec.label,
          spec.name,
          artifact,
          downloadDir,
          outputDir,
          makeCallback(index),
          sharedTarballName,
        );
        const ok /*: ArtifactResultEntry */ = {
          name: spec.name,
          error: undefined,
          ...r,
        };
        return ok;
      } catch (e) {
        progress.update(index, `  ${spec.name}: FAILED - ${e.message}`);
        const failed /*: ArtifactResultEntry */ = {
          name: spec.name,
          error: e.message,
        };
        return failed;
      }
    }),
  );
  log('');

  const succeeded = results.filter(r => r.error == null);
  const failed = results.filter(r => r.error != null);

  log('='.repeat(60));
  if (succeeded.length > 0) {
    log('Extracted xcframeworks:');
    log('');
    for (const r of succeeded) {
      if (r.error == null) {
        log(`  ${r.name}`);
        log(`    path: ${displayPath(r.xcframeworkPath)}`);
        log('');
      }
    }
  }
  // Abort on ANY failure — every artifact (React, ReactNativeDependencies,
  // hermes-engine + the headers companions) is required; proceeding with a
  // partial set would only surface as a confusing build error in Xcode. We
  // also intentionally do NOT
  // write artifacts.json when there are failures: the orchestrator uses its
  // presence as the "already present" signal, so a partial write would mask
  // the problem and prevent retries.
  if (failed.length > 0) {
    log('Failed:');
    for (const r of failed) {
      warn(`  ${r.name}: ${r.error ?? 'unknown error'}`);
    }
    die(
      `Failed to download ${failed.length} of ${results.length} artifact(s): ` +
        failed.map(r => r.name).join(', '),
    );
  }

  // Write artifacts.json only on full success.
  const artifactsJson /*: {[string]: {xcframeworkPath: string, url: string}} */ =
    {};
  for (const r of succeeded) {
    if (r.error == null) {
      artifactsJson[r.name] = {xcframeworkPath: r.xcframeworkPath, url: r.url};
    }
  }
  // The headers-only companions are staged by their parent's extraction (or
  // by the --headers-tarball / --deps-headers-tarball overrides above, which
  // already produced entries). A slot without them cannot resolve any
  // non-React namespace (ReactNativeHeaders) or the <folly/...>-style deps
  // includes (the sidecar; ReactNativeHeaders is pure-RN) — fail closed
  // rather than hand Xcode a package graph that breaks with header-not-found
  // errors much later.
  const companions = [
    {
      name: 'ReactNativeHeaders',
      parent: 'React',
      advice:
        'The React core tarball predates the headers-spec layout — use a ' +
        'matching react-native version, or pass --headers-tarball / ' +
        'RN_HEADERS_TARBALL_PATH.',
    },
    {
      name: DEPS_HEADERS_NAME,
      parent: 'ReactNativeDependencies',
      advice:
        'The ReactNativeDependencies tarball predates the headers sidecar — ' +
        'use a matching react-native version, or pass ' +
        '--deps-headers-tarball / RN_DEPS_HEADERS_TARBALL_PATH.',
    },
  ];
  for (const {name, parent, advice} of companions) {
    if (artifactsJson[name] != null) {
      continue;
    }
    const companionPath = path.join(outputDir, `${name}.xcframework`);
    if (!fs.existsSync(companionPath)) {
      die(`${name}.xcframework is missing from the artifact slot. ${advice}`);
    }
    const parentEntry = artifactsJson[parent];
    artifactsJson[name] = {
      xcframeworkPath: companionPath,
      url: parentEntry != null ? parentEntry.url : '',
    };
  }
  const artifactsJsonPath = path.join(outputDir, 'artifacts.json');
  fs.writeFileSync(
    artifactsJsonPath,
    JSON.stringify(artifactsJson, null, 2) + '\n',
    'utf8',
  );
  log(`Artifact index: ${displayPath(artifactsJsonPath)}`);
}

// Canonical set of xcframework artifacts an SPM slot must provide. The
// xcodeproj references them as package products; missing any one surfaces as
// "Missing package product" only at Xcode build time. Used by
// `setup-apple-spm.js` to validate the cache before skipping a re-download.
// ReactNativeHeaders (pure-RN) and ReactNativeDependenciesHeaders (the deps
// sidecar) are the two headers-only targets — both required: without the
// sidecar no <folly/...>-style include resolves.
const REQUIRED_ARTIFACTS = [
  'React',
  'ReactNativeHeaders',
  'ReactNativeDependencies',
  'ReactNativeDependenciesHeaders',
  'hermes-engine',
];

/**
 * Returns null if `artifacts.json` is present, complete (covers every entry
 * in REQUIRED_ARTIFACTS), and each entry's xcframework dir exists on disk.
 * Otherwise returns a string describing what's wrong — caller treats that as
 * "needs re-download". Catches stale partial-write states from older runs
 * that didn't fail loudly on download errors.
 */
function validateArtifactsCache(
  artifactsDir /*: string */,
) /*: string | null */ {
  const artifactsJsonPath = path.join(artifactsDir, 'artifacts.json');
  if (!fs.existsSync(artifactsJsonPath)) {
    return `artifacts.json missing in ${artifactsDir}`;
  }
  let json /*: {[string]: {xcframeworkPath: string, url: string}} */ = {};
  try {
    // $FlowFixMe[unclear-type] JSON.parse returns any
    const parsed /*: any */ = JSON.parse(
      fs.readFileSync(artifactsJsonPath, 'utf8'),
    );
    json = parsed;
  } catch (e) {
    return `artifacts.json is unreadable: ${e.message}`;
  }
  for (const name of REQUIRED_ARTIFACTS) {
    const entry = json[name];
    if (entry == null) {
      return `artifacts.json missing entry for "${name}"`;
    }
    if (!fs.existsSync(entry.xcframeworkPath)) {
      return `xcframework for "${name}" not found at ${entry.xcframeworkPath}`;
    }
  }
  // The Hermes public headers must be staged for headers-compose to fold
  // `<hermes/...>` into ReactNativeHeaders. A slot from older tooling won't
  // have them — report incomplete so ensureArtifacts re-runs the download
  // (which, with the xcframeworks already present, only backfills the headers
  // from the cached tarball — no network re-download).
  if (!fs.existsSync(path.join(artifactsDir, 'hermes-headers', 'hermes'))) {
    return 'Hermes public headers not staged (hermes-headers/hermes)';
  }
  return null;
}

if (require.main === module) {
  main().catch(err => {
    console.error(`\x1b[31m${err.message}\x1b[0m`);
    process.exitCode = 1;
  });
}

module.exports = {
  main,
  resolveCacheSlotVersion,
  resolveHermesArtifact,
  resolveLocalHermesCompilerVersion,
  REQUIRED_ARTIFACTS,
  validateArtifactsCache,
  // Exposed for unit tests (pure / fetch-stubbable helpers).
  mavenRepositoryUrls,
  reactNativeMavenMirrorEnabled,
  rnCoreReleaseUrls,
  rnDepsReleaseUrls,
  hermesReleaseUrls,
  firstExistingUrl,
  resolveSnapshotUrl,
  resolveNightlyVersion,
  resolveLatestV1Version,
  resolveRNCoreArtifact,
  resolveRNDepsArtifact,
  exists,
  formatBytes,
  formatSpeed,
  findFirst,
  extractXCFramework,
};