UNPKG

react-native

Version:

A framework for building native apps using React

2,787 lines • 103 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';

/**
 * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager
 * integration toolkit for an existing `<App>.xcodeproj`.
 *
 * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build
 * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to
 * a user's existing project — purely additively, recording every edit in a
 * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used
 * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI.
 */

const {readFlavoredFrameworksManifest} = require('./flavored-frameworks');
const {parseConfigCommandJson} = require('./generate-spm-autolinking-config');
const {
  addArrayMembers,
  addArrayStringValues,
  commentSafe,
  ensureScalarField,
  findApplicationTargets,
  findField,
  findObjectByUuid,
  findProjectObject,
  insertObjectsIntoSection,
  namespacedUUID,
  quoteIfNeeded,
  removeArrayMembersByUuid,
  removeArrayStringValues,
  removeDanglingJavaScriptCoreRef,
  removeEmptyPodsGroup,
  removeField,
  removeObjectByUuid,
  serializeEntry,
  setScalarField,
  uuidComment,
} = require('./spm-pbxproj');
const {
  AUTOLINKED_PACKAGE_NAME,
  REACT_CODEGEN_APP_PRODUCTS,
  REACT_CODEGEN_PACKAGE_NAME,
  REACT_NATIVE_PACKAGE_NAME,
  REACT_NATIVE_PRODUCTS,
  isValidScriptPhaseId,
  isValidScriptPhaseName,
  makeLogger,
  remotePackageConfig,
} = require('./spm-utils');
const fs = require('node:fs');
const path = require('node:path');

/*:: import type {
  FlavoredFrameworkManifestEntry,
  PluginScriptPhase,
  XcframeworkSlice,
} from './spm-types'; */

const {log} = makeLogger('generate-spm-xcodeproj');

// Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in
// place. Records the host project's root UUID + every edit so `spm deinit`
// (removeSpmInjection) can surgically revert and re-runs stay idempotent.
const SPM_INJECTED_MARKER = '.spm-injected.json';

// Manifest of plugin-contributed sources that must COMPILE INTO THE APP TARGET
// (e.g. Expo's ExpoModulesProvider.swift — an `@objc` class only reaches the
// ObjC classlist, and so is discoverable via NSClassFromString, when it
// compiles into the app target, NOT the static Autolinked aggregate). Written
// by generate-spm-autolinking.js (the plugin merge) BEFORE setupXcodeproj runs
// on both `add` and `update`, so the injector reads it synchronously. Path is
// relative to the app root (== SRCROOT / the .xcodeproj's dir).
const SPM_GENERATED_SOURCES_MANIFEST = path.join(
  'build',
  'generated',
  'autolinking',
  '.spm-plugin-generated-sources.json',
);

// Manifest of plugin-contributed build-time shell phases for the app target —
// SwiftPM has no `script_phase`, so a framework that must generate content into
// the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one
// through the plugin contract.
const SPM_SCRIPT_PHASES_MANIFEST = path.join(
  'build',
  'generated',
  'autolinking',
  '.spm-plugin-script-phases.json',
);

// The single navigator group all injected generated sources are parented under
// (created on first use). Its namespacedUUID id + display name.
const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources';
const SPM_GENERATED_SOURCES_GROUP_NAME = 'SPM Generated Sources';

// pbxproj `lastKnownFileType` per source extension. v1 plugins emit Swift only;
// .m/.mm are mapped as future-proofing (the plugin contract permits ObjC/ObjC++
// sources). An unmapped extension is skipped with a loud log.
const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = {
  '.swift': 'sourcecode.swift',
  '.m': 'sourcecode.c.objc',
  '.mm': 'sourcecode.cpp.objcpp',
};

// Maps each SPM product to its sub-package path (relative to app root).
// The xcodeproj must reference each sub-package directly so Xcode can
// resolve the product dependencies — SPM doesn't expose transitive products.
const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ =
  [
    ...REACT_NATIVE_PRODUCTS.map(product => ({
      product,
      packagePath: 'build/xcframeworks',
      packageName: REACT_NATIVE_PACKAGE_NAME,
    })),
    {
      product: AUTOLINKED_PACKAGE_NAME,
      packagePath: 'build/generated/autolinking',
      packageName: AUTOLINKED_PACKAGE_NAME,
    },
    ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({
      product,
      packagePath: 'build/generated/ios',
      packageName: REACT_CODEGEN_PACKAGE_NAME,
    })),
  ];

/*::
type RemoteCfg = {url: string, version: string, identity: string};
// Precise record of the build-setting edits injection made to ONE build config,
// so deinit can reverse exactly those (and nothing the user already had).
type BuildSettingChange = {
  configUuid: string,
  createdArrayKeys: Array<string>,
  appendedArrayValues: {[string]: Array<string>},
  createdScalars: Array<string>,
  // Scalars whose pre-injection value was replaced (key → original raw
  // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
  // CocoaPods is deintegrated. Deinit restores the original.
  replacedScalars?: {[string]: string},
  // Array settings that existed as a SCALAR and were promoted to a `( … )`
  // array (key → the pre-injection raw value text, quotes included). Deinit
  // restores that value verbatim; removing the injected members would leave
  // the promoted array and its `"$(inherited)"` seed behind.
  promotedArrayScalars?: {[string]: string},
};
// An array field injection CREATED (rather than appended to a pre-existing
// one), so deinit removes the whole field and lands byte-identical.
type CreatedArrayField = {container: 'project' | 'target', key: string};
// A plugin-contributed source, normalized for pbxproj emission. `path` is
// SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
// matching pbxproj token ('SOURCE_ROOT' or '"<absolute>"').
type GeneratedSource = {path: string, name: string, sourceTree: string, fileType: string};
type SpmGraph = {
  uniquePackages: Array<{packagePath: string, packageName: string}>,
  localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>,
  remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string},
  products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>,
};
*/

/**
 * Resolve the SPM dependency graph (package references + product
 * dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES.
 * `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root
 * UUID so injected IDs are stable across re-runs and collision-safe.
 */
function buildSpmDependencyGraph(
  mkUuid /*: (section: string, id: string) => string */,
  remote /*: ?RemoteCfg */,
) /*: SpmGraph */ {
  // Remote mode: ReactNative-family products move to the remote package.
  const productPackages = SPM_PRODUCT_PACKAGES.map(e =>
    remote != null && e.packagePath === 'build/xcframeworks'
      ? {...e, packagePath: 'REMOTE', packageName: remote.identity}
      : e,
  );
  const uniquePackages = Array.from(
    new Map(
      productPackages
        .filter(e => e.packagePath !== 'REMOTE')
        .map(e => [
          e.packagePath,
          {packagePath: e.packagePath, packageName: e.packageName},
        ]),
    ).values(),
  );
  const localPkgRefs = uniquePackages.map(pkg => ({
    uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath),
    packagePath: pkg.packagePath,
    comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`,
  }));
  const remotePkgRef =
    remote != null
      ? {
          uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url),
          url: remote.url,
          version: remote.version,
          identity: remote.identity,
          comment: `XCRemoteSwiftPackageReference "${remote.identity}"`,
        }
      : null;
  const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r]));
  const products = productPackages.map(entry => {
    const {product, packagePath} = entry;
    const isRemote = packagePath === 'REMOTE' && remotePkgRef != null;
    const pkgRefUuid = isRemote
      ? // $FlowFixMe[incompatible-use] guarded by isRemote
        remotePkgRef.uuid
      : // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath
        localByPath.get(packagePath).uuid;
    const refComment = isRemote
      ? // $FlowFixMe[incompatible-use] guarded by isRemote
        `XCRemoteSwiftPackageReference "${remotePkgRef.identity}"`
      : `XCLocalSwiftPackageReference "${packagePath}"`;
    return {
      product,
      depUuid: mkUuid('XCSwiftPackageProductDependency', product),
      buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`),
      pkgRefUuid,
      refComment,
    };
  });
  return {uniquePackages, localPkgRefs, remotePkgRef, products};
}

/**
 * Render the SPM graph into pbxproj section entry objects the in-place injector
 * splices into an existing project.
 */
/*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */

function spmGraphToEntries(
  graph /*: SpmGraph */,
) /*: {localRefs: Array<PbxEntryT>, remoteRef: ?PbxEntryT, productDeps: Array<PbxEntryT>, buildFiles: Array<PbxEntryT>} */ {
  const localRefs /*: Array<PbxEntryT> */ = graph.localPkgRefs.map(ref => ({
    uuid: ref.uuid,
    comment: ref.comment,
    fields: {
      isa: 'XCLocalSwiftPackageReference',
      relativePath: quoteIfNeeded(ref.packagePath),
    },
  }));
  const remote = graph.remotePkgRef;
  const remoteRef /*: ?PbxEntryT */ =
    remote != null
      ? {
          uuid: remote.uuid,
          comment: remote.comment,
          fields: {
            isa: 'XCRemoteSwiftPackageReference',
            repositoryURL: quoteIfNeeded(remote.url),
            requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`,
          },
        }
      : null;
  const productDeps /*: Array<PbxEntryT> */ = graph.products.map(p => ({
    uuid: p.depUuid,
    comment: p.product,
    fields: {
      isa: 'XCSwiftPackageProductDependency',
      package: `${p.pkgRefUuid} /* ${p.refComment} */`,
      productName: quoteIfNeeded(p.product),
    },
  }));
  const buildFiles /*: Array<PbxEntryT> */ = graph.products.map(p => ({
    uuid: p.buildFileUuid,
    comment: `${p.product} in Frameworks`,
    fields: {
      isa: 'PBXBuildFile',
      productRef: `${p.depUuid} /* ${p.product} */`,
    },
  }));
  return {localRefs, remoteRef, productDeps, buildFiles};
}

/**
 * Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept
 * either a plain path array or an already-serialized pbxproj list.
 * `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right
 * after `isa`, so a project Xcode rewrites stays diff-free) and is omitted
 * entirely when false. `comment` overrides the cosmetic `/* … *​/` label, which
 * otherwise derives from `name`.
 */
function shellScriptPhase(
  phaseUUID /*: string */,
  name /*: string */,
  script /*: string */,
  options /*: {inputPaths?: ?(string | ReadonlyArray<string>), outputPaths?: ?(string | ReadonlyArray<string>), alwaysOutOfDate?: ?boolean, comment?: string} */ = {},
) /*: {uuid: string, comment: string, fields: {[string]: string}} */ {
  const empty = '(\n\t\t\t)';
  const pathList = (
    value /*: ?(string | ReadonlyArray<string>) */,
  ) /*: string */ => {
    if (value == null) {
      return empty;
    }
    return typeof value === 'string' ? value : pbxPathList(value);
  };
  return {
    uuid: phaseUUID,
    comment: options.comment ?? name,
    fields: {
      isa: 'PBXShellScriptBuildPhase',
      ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}),
      buildActionMask: '2147483647',
      files: empty,
      inputFileListPaths: empty,
      inputPaths: pathList(options.inputPaths),
      name: quoteIfNeeded(name),
      outputFileListPaths: empty,
      outputPaths: pathList(options.outputPaths),
      runOnlyForDeploymentPostprocessing: '0',
      // React Native's own bodies here start with `set -euo pipefail`, which
      // /bin/sh rejects on a host where it isn't bash (e.g. dash's `set -o
      // pipefail: Illegal option`). Run every phase under bash, including
      // plugin-contributed ones, so a plugin's body can rely on it too.
      shellPath: '/bin/bash',
      shellScript: quoteIfNeeded(script),
    },
  };
}

function frameworkSettingPrefix(id /*: string */) /*: string */ {
  return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`;
}

function flavorForBuildConfiguration(
  configurationName /*: string */,
) /*: 'debug' | 'release' */ {
  const lower = configurationName.toLowerCase();
  return lower.includes('debug') || lower.includes('development')
    ? 'debug'
    : 'release';
}

function buildConfigurationName(
  text /*: string */,
  configUuid /*: string */,
) /*: string */ {
  const config = findObjectByUuid(text, configUuid);
  const name = config != null ? findField(text, config, 'name') : null;
  if (name == null) {
    throw new Error(`pbxproj: build configuration ${configUuid} has no name`);
  }
  return name.value.replace(/^"|"$/g, '');
}

function frameworkConditionalSettings(
  frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: Array<{key: string, value: string}> */ {
  const settings /*: Array<{key: string, value: string}> */ = [];
  for (const framework of frameworks) {
    const prefix = frameworkSettingPrefix(framework.id);
    const bySdk /*: Map<string, Array<XcframeworkSlice>> */ = new Map();
    // The injected target is an Apple mobile/Catalyst application. Native
    // macOS slices share `sdk=macosx*` with Catalyst and cannot be
    // distinguished by an XCBuildConfiguration condition, so use the Catalyst
    // slice and leave native-mac packaging out of this iOS integration.
    for (const slice of framework.slices.filter(
      candidate => candidate.platform !== 'macos',
    )) {
      const existing = bySdk.get(slice.sdk) ?? [];
      existing.push(slice);
      bySdk.set(slice.sdk, existing);
    }
    for (const [sdk, slices] of bySdk) {
      const emit = (slice /*: XcframeworkSlice */, condition /*: string */) => {
        const root =
          `$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/` +
          `${framework.artifactRelativePath}/${slice.libraryIdentifier}`;
        settings.push(
          {
            key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`),
            value: quoteIfNeeded(`${root}/${slice.libraryPath}`),
          },
          {
            key: quoteIfNeeded(`${prefix}_BINARY${condition}`),
            value: quoteIfNeeded(`${root}/${slice.binaryPath}`),
          },
          {
            key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`),
            value: quoteIfNeeded(root),
          },
        );
      };
      if (slices.length === 1) {
        emit(slices[0], `[sdk=${sdk}]`);
        continue;
      }
      const seenArchitectures /*: Set<string> */ = new Set();
      for (const slice of slices) {
        for (const architecture of slice.architectures) {
          if (seenArchitectures.has(architecture)) {
            throw new Error(
              `${framework.frameworkName} has ambiguous ${sdk}/${architecture} slices`,
            );
          }
          seenArchitectures.add(architecture);
          emit(slice, `[sdk=${sdk}][arch=${architecture}]`);
        }
      }
    }
  }
  return settings;
}

function frameworkArrayBuildSettings(
  frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: Array<{key: string, values: Array<string>}> */ {
  return [
    {
      key: 'OTHER_LDFLAGS',
      values: [
        '"-ObjC"',
        ...frameworks.map(
          framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`,
        ),
      ],
    },
    {
      key: 'FRAMEWORK_SEARCH_PATHS',
      values: frameworks.map(
        framework => `"$(${frameworkSettingPrefix(framework.id)}_SEARCH_PATH)"`,
      ),
    },
    {
      key: 'LD_RUNPATH_SEARCH_PATHS',
      values: ['"@executable_path/Frameworks"'],
    },
  ];
}

function pbxPathList(paths /*: ReadonlyArray<string> */) /*: string */ {
  if (paths.length === 0) {
    return '(\n\t\t\t)';
  }
  return `(\n${paths
    .map(value => `\t\t\t\t${quoteIfNeeded(value)},\n`)
    .join('')}\t\t\t)`;
}

function buildEmbedFrameworksScript(
  frameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */,
) /*: string */ {
  const validations = frameworks
    .map(framework => {
      const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
      return `validate_framework "\${${variable}:-}" "${framework.frameworkName}.framework"`;
    })
    .join('\n');
  const copies = frameworks
    .map(framework => {
      const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`;
      return `copy_and_sign "\${${variable}:-}" "${framework.frameworkName}.framework"`;
    })
    .join('\n');
  return `set -euo pipefail

destination="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH"
mkdir -p "$destination"

validate_framework() {
  source="$1"
  name="$2"
  if [ -z "$source" ] || [ ! -d "$source" ]; then
    echo "error: React Native SwiftPM framework '$name' is unavailable for configuration '$CONFIGURATION' and SDK '$SDK_NAME': $source"
    exit 1
  fi
  binary="\${name%.framework}"
  if [ ! -e "$source/$binary" ] && [ ! -e "$source/Versions/Current/$binary" ]; then
    echo "error: React Native SwiftPM framework '$name' is invalid for configuration '$CONFIGURATION': expected $source/$binary or $source/Versions/Current/$binary"
    exit 1
  fi
}

copy_and_sign() {
  source="$1"
  name="$2"
  /usr/bin/rsync -a --delete "$source/" "$destination/$name/"
  if [ "\${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]; then
    identity="\${EXPANDED_CODE_SIGN_IDENTITY:--}"
    if [ "$identity" = "-" ]; then
      /usr/bin/codesign --force --sign - --timestamp=none --preserve-metadata=identifier,entitlements,flags "$destination/$name"
    else
      /usr/bin/codesign --force --sign "$identity" --preserve-metadata=identifier,entitlements,flags "$destination/$name"
    fi
  fi
}

${validations}
${copies}
`;
}

/**
 * The app target's `buildPhases` members, in the order the file lists them.
 * Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied
 * phase name, and one that happens to look like a UUID would otherwise read as an
 * extra member — leaving the actual order permanently at odds with the declared
 * one, and offering a non-member as a re-seating anchor.
 */
function buildPhaseOrder(
  text /*: string */,
  target /*: {bodyOpen: number, bodyClose: number, ...} */,
) /*: Array<string> */ {
  const field = findField(text, target, 'buildPhases');
  if (field == null) {
    return [];
  }
  return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map(
    m => m[1],
  );
}

/**
 * Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own
 * definition line and on every array-member line referencing it. Xcode
 * normalizes those comments on its next write, so leaving a stale one behind
 * (after a plugin renames a phase) plants a spurious diff in the user's repo.
 * No-op when the comment already reads `comment`.
 */
function setUuidComment(
  text /*: string */,
  uuid /*: string */,
  comment /*: string */,
) /*: string */ {
  return text.replace(
    new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'),
    (_match, head, tail) => `${head}${uuidComment(comment)}${tail}`,
  );
}

/*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */

/**
 * Seat the plugin phases in the order the manifest declares: `beforeCompile`
 * ones directly after the Sync SPM Autolinking phase (which stays first — it
 * regenerates the content everything else reads) and always before Sources,
 * `end` ones at the true end of `buildPhases`, after the app's own JS-bundle
 * phase. The other members keep their relative order.
 *
 * `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never
 * re-seats its own sync phase, so a user who drags it below Sources would
 * otherwise have every `beforeCompile` phase seated after compilation — the one
 * thing the position promises not to do. Without a Sources phase the sync phase
 * is the anchor.
 *
 * Rewrites the membership lines ONLY when the actual order differs from that,
 * which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter`
 * and `addArrayMembers` both short-circuit on a UUID that is already a member,
 * so re-placing has to be driven from here. A phase a user dragged elsewhere in
 * Xcode is therefore moved back: the declared position wins.
 */
function seatScriptPhases(
  input /*: string */,
  targetUuid /*: string */,
  syncPhaseUuid /*: string */,
  phases /*: ReadonlyArray<SeatedPhase> */,
  sourcesPhaseUuid /*: ?string */,
) /*: string */ {
  const pluginUuids = new Set(phases.map(p => p.uuid));
  const actual = buildPhaseOrder(
    input,
    findApplicationTargetByUuid(input, targetUuid),
  );
  const others = actual.filter(uuid => !pluginUuids.has(uuid));
  const beforeCompile = phases.filter(p => p.position === 'beforeCompile');
  const atEnd = phases.filter(p => p.position !== 'beforeCompile');
  const sourcesAt =
    sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1;
  const afterSyncAt = others.indexOf(syncPhaseUuid) + 1;
  // 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile
  // phases lead the array, matching addArrayMembers' prepend fallback below.
  const insertAt =
    sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt;
  const desired = [
    ...others.slice(0, insertAt),
    ...beforeCompile.map(p => p.uuid),
    ...others.slice(insertAt),
    ...atEnd.map(p => p.uuid),
  ];
  if (
    desired.length === actual.length &&
    desired.every((uuid, i) => uuid === actual[i])
  ) {
    return input;
  }
  // File-wide removal is safe for a shell-phase UUID: it appears on its own
  // definition line (which ends `= {`, never matched) and on `buildPhases`
  // member lines. The uncovered case is a user who copied the same phase object
  // into a SECOND target's buildPhases — it is stripped there too.
  let text = removeArrayMembersByUuid(input, [...pluginUuids]);
  const target = () => findApplicationTargetByUuid(text, targetUuid);
  let anchor = insertAt > 0 ? others[insertAt - 1] : null;
  for (const member of beforeCompile) {
    text =
      anchor == null
        ? addArrayMembers(text, target(), 'buildPhases', [member], {
            prepend: true,
          })
        : addBuildPhaseAfter(text, target(), anchor, member);
    anchor = member.uuid;
  }
  for (const member of atEnd) {
    text = addArrayMembers(text, target(), 'buildPhases', [member]);
  }
  return text;
}

function addBuildPhaseAfter(
  text /*: string */,
  target /*: {bodyOpen: number, bodyClose: number, ...} */,
  afterUuid /*: string */,
  member /*: {uuid: string, comment: string, ...} */,
) /*: string */ {
  const field = findField(text, target, 'buildPhases');
  if (field == null || field.value.includes(member.uuid)) {
    return text;
  }
  const after = new RegExp(`(^|\\n)([\\t ]*)${afterUuid}\\b[^\\n]*,`).exec(
    field.value,
  );
  if (after == null) {
    return addArrayMembers(text, target, 'buildPhases', [member]);
  }
  const absoluteStart = field.valueStart + after.index;
  const lineEnd = text.indexOf('\n', absoluteStart + after[0].length);
  const indent = after[2];
  const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`;
  return text.slice(0, lineEnd) + line + text.slice(lineEnd);
}

// The node + react-native-dir resolution preamble shared by the sync build
// phase and scheme pre-action. Both dispatch DIRECTLY into react-native's
// scripts rather than through 'npx react-native' — that CLI requires
// @react-native-community/cli (absent in e.g. Expo apps), so it would exit
// non-zero and the failure would be silently swallowed.
function nodeAndRnDirPreamble(reactNativePath /*: string */) /*: string */ {
  return `set -euo pipefail

# ---------------------------------------------------------------------------
# Resolve a node binary and the react-native package dir at BUILD TIME.
# ---------------------------------------------------------------------------
NODE_BINARY="\${NODE_BINARY:-}"
if [ -z "$NODE_BINARY" ]; then
  # Source RN's standard app-local node-path files. They reference vars that
  # may be unset and may return non-zero, so relax nounset AND errexit while
  # sourcing — a buggy user .xcode.env must degrade to PATH-based node
  # resolution below, not silently abort every build.
  set +eu
  if [ -f "$SRCROOT/.xcode.env" ]; then
    . "$SRCROOT/.xcode.env"
  fi
  if [ -f "$SRCROOT/.xcode.env.local" ]; then
    . "$SRCROOT/.xcode.env.local"
  fi
  set -eu
  NODE_BINARY="\${NODE_BINARY:-}"
fi
if [ -z "$NODE_BINARY" ]; then
  NODE_BINARY="$(command -v node 2>/dev/null || true)"
fi

# Resolve react-native's dir FROM THE APP (require.resolve), not a
# generation-time baked path — the baked path goes stale in pnpm / hoisted
# stores. Fall back to the baked path if resolution fails or the resolved dir
# has no setup-apple-spm.js.
RN_DIR=""
if [ -n "$NODE_BINARY" ]; then
  RN_DIR="$(cd "$SRCROOT" && "$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json'))" 2>/dev/null || true)"
fi
if [ -z "$RN_DIR" ] || [ ! -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
  RN_DIR="${reactNativePath}"
fi`;
}

// Shared: the STALE-input check + conditional codegen/autolinking sync dispatch.
// Runtime framework slots are never touched here; add/update owns them.
function syncStaleCheckAndDispatch() /*: string */ {
  return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp"
STALE=0

# Find project root (where package.json lives — may be an ancestor of SRCROOT)
PROJECT_ROOT="$SRCROOT"
while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do
  PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
done
if [ ! -f "$PROJECT_ROOT/package.json" ]; then
  PROJECT_ROOT="$SRCROOT"
fi

# Check 1: dependency inputs (covers app projects after any package manager install)
for INPUT in \\
  "$PROJECT_ROOT/package.json" \\
  "$PROJECT_ROOT/react-native.config.js"; do
  if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
    STALE=1
    break
  fi
done

# Check workspace lockfiles and package-manager metadata. These cover package
# managers that do not reliably bump node_modules mtimes, and Yarn PnP projects
# that do not have node_modules at all.
if [ "$STALE" -eq 0 ]; then
  DIR="$PROJECT_ROOT"
  while [ "$DIR" != "/" ]; do
    for INPUT in \\
      "$DIR/package-lock.json" \\
      "$DIR/npm-shrinkwrap.json" \\
      "$DIR/yarn.lock" \\
      "$DIR/pnpm-lock.yaml" \\
      "$DIR/bun.lock" \\
      "$DIR/bun.lockb" \\
      "$DIR/.pnp.cjs" \\
      "$DIR/.pnp.loader.mjs"; do
      if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then
        STALE=1
        break
      fi
    done
    if [ "$STALE" -eq 1 ]; then
      break
    fi
    DIR="$(dirname "$DIR")"
  done
fi

# Check node_modules mtime. In monorepos, node_modules may be hoisted to any
# ancestor between the app package and the workspace root.
if [ "$STALE" -eq 0 ]; then
  DIR="$PROJECT_ROOT"
  while [ "$DIR" != "/" ]; do
    NM_DIR="$DIR/node_modules"
    if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then
      STALE=1
      break
    fi
    DIR="$(dirname "$DIR")"
  done
fi

# Also check the app root directly when SRCROOT is not the package root.
if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then
  if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then
    STALE=1
  fi
fi

# Check 1.5: watched paths (mixed dirs AND files). Dirs catch add/remove of
# source files in spm.modules and autolinked deps (dir mtime updates on both);
# files catch edits to a dep's checked-in Package.swift / plugin manifests that
# would not bump any parent dir mtime. A path that has VANISHED (renamed/moved
# module root) forces a re-sync so the autolinker surfaces the real, actionable
# config error rather than the build failing later on dangling-symlink noise.
WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths"
if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then
  while IFS= read -r P; do
    [ -z "$P" ] && continue
    if [ -d "$P" ]; then
      # .swiftpm holds Xcode's own per-user scheme state, which it rewrites
      # during a build — reading it as a change makes every IDE build re-sync.
      if [ -n "$(find "$P" -name .swiftpm -prune -o -newer "$STAMP" -print -quit 2>/dev/null)" ]; then
        STALE=1
        break
      fi
    elif [ -f "$P" ]; then
      if [ "$P" -nt "$STAMP" ]; then
        STALE=1
        break
      fi
    else
      STALE=1
      break
    fi
  done < "$WATCH_FILE"
fi

# Check 2: codegen spec files changed via git (covers monorepo after git pull)
if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then
  STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
  LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0)
  if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then
    STALE=1
  fi
fi

if [ ! -f "$STAMP" ]; then
  STALE=1
fi

# Re-sync codegen + autolinking when a dependency input changed. Runtime
# framework slots and Xcode linker settings are only changed by spm update.
if [ "$STALE" -eq 1 ]; then
  echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..."

  WITH_ENVIRONMENT="$RN_DIR/scripts/xcode/with-environment.sh"

  if [ -f "$WITH_ENVIRONMENT" ]; then
    # with-environment.sh references PODS_ROOT and $1, which may be unset.
    # Temporarily disable nounset to avoid failures when sourcing.
    export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}"
    set +u
    . "$WITH_ENVIRONMENT"
    set -u
  fi

  cd "$SRCROOT"
  # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase
  # under \`set -e\` — the whole point is to branch on the code below (2 = fail
  # the build with a scaffold hint; other non-zero = warn but don't break).
  RC=0
  if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then
    # Direct, dependency-free dispatch (no \`npx react-native\`, which needs
    # @react-native-community/cli).
    "$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync || RC=$?
  elif command -v npx >/dev/null 2>&1; then
    npx react-native spm sync || RC=$?
  else
    echo "warning: node/npx not found — skipping SPM sync"
  fi
  if [ "$RC" -eq 2 ]; then
    # Exit 2 = an autolinked community dependency has no Package.swift. The
    # autolinker already printed an \`error:\` line per dep (so Xcode shows them
    # and the fix). Fail the build — the developer must run
    # \`npx react-native spm scaffold\` from a terminal to generate the manifest.
    exit 1
  elif [ "$RC" -ne 0 ]; then
    echo "warning: SPM sync failed — build may use stale codegen/autolinking"
  fi
fi
`;
}

// Scheme pre-action: re-run codegen + autolinking before package resolution.
function buildSchemePreActionScript(
  reactNativePath /*: string */,
) /*: string */ {
  return `${nodeAndRnDirPreamble(reactNativePath)}

${syncStaleCheckAndDispatch()}
`;
}

// The in-target phase is only an autolinking safety net. Runtime framework
// selection is expressed entirely through build settings and the independent
// Embed React Native Flavored Frameworks phase.
function buildSyncAutolinkingScript(
  reactNativePath /*: string */,
) /*: string */ {
  return `${nodeAndRnDirPreamble(reactNativePath)}

${syncStaleCheckAndDispatch()}
`;
}
// XML-attribute escape (the five named entities). The sync script uses `>`
// and `&` for redirection and bg/and chains, plus `<` for heredocs and
// comparisons — all of which break Xcode's scheme parser if left raw.
function escapeXmlAttribute(s /*: string */) /*: string */ {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

// The inverse of escapeXmlAttribute. `&amp;` is expanded LAST so an entity that
// was itself escaped (`&lt;` → `&amp;lt;`) round-trips back to its own text
// rather than to `<`.
function unescapeXmlAttribute(s /*: string */) /*: string */ {
  return s
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&amp;/g, '&');
}

function generateXcscheme(
  appName /*: string */,
  targetUUID /*: string */,
  projName /*: string */,
  syncScript /*: string */,
) /*: string */ {
  const escapedSync = escapeXmlAttribute(syncScript);
  return `<?xml version="1.0" encoding="UTF-8"?>
<Scheme
   LastUpgradeVersion = "1600"
   version = "1.7">
   <BuildAction
      parallelizeBuildables = "YES"
      buildImplicitDependencies = "YES">
      <PreActions>
         <ExecutionAction
            ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
            <ActionContent
               title = "Sync SPM Autolinking"
               scriptText = "${escapedSync}"
               shellToInvoke = "/bin/bash">
               <EnvironmentBuildable>
                  <BuildableReference
                     BuildableIdentifier = "primary"
                     BlueprintIdentifier = "${targetUUID}"
                     BuildableName = "${appName}.app"
                     BlueprintName = "${appName}"
                     ReferencedContainer = "container:${projName}.xcodeproj">
                  </BuildableReference>
               </EnvironmentBuildable>
            </ActionContent>
         </ExecutionAction>
      </PreActions>
      <BuildActionEntries>
         <BuildActionEntry
            buildForTesting = "YES"
            buildForRunning = "YES"
            buildForProfiling = "YES"
            buildForArchiving = "YES"
            buildForAnalyzing = "YES">
            <BuildableReference
               BuildableIdentifier = "primary"
               BlueprintIdentifier = "${targetUUID}"
               BuildableName = "${appName}.app"
               BlueprintName = "${appName}"
               ReferencedContainer = "container:${projName}.xcodeproj">
            </BuildableReference>
         </BuildActionEntry>
      </BuildActionEntries>
   </BuildAction>
   <TestAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      shouldUseLaunchSchemeArgsEnv = "YES"
      shouldAutocreateTestPlan = "YES">
   </TestAction>
   <LaunchAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      launchStyle = "0"
      useCustomWorkingDirectory = "NO"
      ignoresPersistentStateOnLaunch = "NO"
      debugDocumentVersioning = "YES"
      debugServiceExtension = "internal"
      allowLocationSimulation = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "${targetUUID}"
            BuildableName = "${appName}.app"
            BlueprintName = "${appName}"
            ReferencedContainer = "container:${projName}.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
   </LaunchAction>
   <ProfileAction
      buildConfiguration = "Release"
      shouldUseLaunchSchemeArgsEnv = "YES"
      savedToolIdentifier = ""
      useCustomWorkingDirectory = "NO"
      debugDocumentVersioning = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "${targetUUID}"
            BuildableName = "${appName}.app"
            BlueprintName = "${appName}"
            ReferencedContainer = "container:${projName}.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
   </ProfileAction>
   <AnalyzeAction
      buildConfiguration = "Debug">
   </AnalyzeAction>
   <ArchiveAction
      buildConfiguration = "Release"
      revealArchiveInOrganizer = "YES">
   </ArchiveAction>
</Scheme>
`;
}

// When the xcodeproj is generated, the referenced SPM package directories
// (build/xcframeworks, autolinked, build/generated/ios) may not exist yet.
// Xcode resolves packages before any build phase runs, so we write minimal
// stub Package.swift files to let resolution succeed. The real generators
// (sync-spm-autolinking.js) overwrite these during the first build.

/*::
type StubPackageDef = {
  packageName: string,
  products: Array<string>,
};
*/

function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ {
  const {packageName, products} = def;
  const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`;
  const productLines = products
    .map(p => `        .library(name: "${p}", targets: ["${stubTarget}"]),`)
    .join('\n');
  return `// swift-tools-version: 5.9
// GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build.
import PackageDescription

let package = Package(
    name: "${packageName}",
    products: [
${productLines}
    ],
    targets: [
        .target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]),
    ]
)
`;
}

/**
 * Ensures each referenced SPM sub-package directory has a valid Package.swift
 * so Xcode can resolve packages before any build phase runs.
 * Skips directories that already contain a Package.swift (from a previous build).
 */
function ensureStubPackages(appRoot /*: string */) /*: void */ {
  // Derive stub definitions from SPM_PRODUCT_PACKAGES
  const byPath = new Map /*:: <string, StubPackageDef> */();
  for (const entry of SPM_PRODUCT_PACKAGES) {
    const existing = byPath.get(entry.packagePath);
    if (existing != null) {
      existing.products.push(entry.product);
    } else {
      byPath.set(entry.packagePath, {
        packageName: entry.packageName,
        products: [entry.product],
      });
    }
  }

  for (const [relPath, def] of byPath) {
    const pkgDir = path.join(appRoot, relPath);
    const pkgSwiftPath = path.join(pkgDir, 'Package.swift');

    if (fs.existsSync(pkgSwiftPath)) {
      continue;
    }

    fs.mkdirSync(pkgDir, {recursive: true});
    fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8');

    // Create minimal stub source file required by SPM
    const stubDir = path.join(pkgDir, '_stub');
    fs.mkdirSync(stubDir, {recursive: true});
    const stubSwift = path.join(stubDir, 'Stub.swift');
    if (!fs.existsSync(stubSwift)) {
      fs.writeFileSync(
        stubSwift,
        '// Placeholder — replaced during first build.\n',
        'utf8',
      );
    }

    log(`Wrote stub Package.swift: ${relPath}/Package.swift`);
  }
}

// ---------------------------------------------------------------------------
// In-place injection: add SPM packages to a user's EXISTING xcodeproj.
//
// This never creates a target or scans sources — it splices the SPM dependency
// graph, the React build settings, and the sync build phase / scheme pre-action
// into the project the user already owns, leaving everything else
// byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so
// hand-tuned signing / capabilities / extra targets survive. Fails loud (the
// caller surfaces the error) when the project is CocoaPods-integrated or its
// shape can't be safely anchored.
// ---------------------------------------------------------------------------

// The React build settings the app target needs to compile against the SPM
// products.
const INJECTED_ARRAY_SETTINGS = [
  {
    key: 'HEADER_SEARCH_PATHS',
    values: ['"$(SRCROOT)/build/generated/autolinking/headers"'],
  },
];

// Array build settings injected only into debug-flavored configurations.
//
// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by
// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app
// template does not commit the setting: CocoaPods injects it at `pod install`
// time (react_native_post_install → set_build_setting
// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug).
// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even
// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL,
// falls back to a main.jsbundle that a Debug build never produced, and the app
// dies at launch with "No script url provided … unsanitizedScriptURLString =
// (null)" while Metro is running right there.
//
// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that
// links the debug xcframeworks also compiles its Swift with DEBUG.
const DEBUG_ARRAY_SETTINGS = [
  {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']},
];

/** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */
function targetBuildConfigUuids(
  text /*: string */,
  targetObj /*: {bodyOpen: number, bodyClose: number, ...} */,
) /*: Array<string> */ {
  const listField = findField(text, targetObj, 'buildConfigurationList');
  if (listField == null) {
    return [];
  }
  const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/);
  if (listMatch == null) {
    return [];
  }
  const listObj = findObjectByUuid(text, listMatch[0]);
  if (listObj == null) {
    return [];
  }
  const configs = findField(text, listObj, 'buildConfigurations');
  if (configs == null) {
    return [];
  }
  const matches = configs.value.match(/[0-9A-Fa-f]{24}/g);
  return matches != null ? Array.from(matches) : [];
}

/** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */
function configUsesPods(
  text /*: string */,
  configUuid /*: string */,
) /*: boolean */ {
  const obj = findObjectByUuid(text, configUuid);
  if (obj == null) {
    return false;
  }
  const base = findField(text, obj, 'baseConfigurationReference');
  return base != null && /Pods[-/]/.test(base.value);
}

/**
 * Inspect an existing pbxproj and decide whether it can be injected. Returns
 * the chosen app target + its config/frameworks anchors, or a refusal reason
 * the caller surfaces (fail-loud).
 */
function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*:
  | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string}
  | {ok: false, reason: string} */ {
  const project = findProjectObject(text);
  if (project == null) {
    return {ok: false, reason: 'no PBXProject object found'};
  }
  const apps = findApplicationTargets(text);
  if (apps.length === 0) {
    return {ok: false, reason: 'no application target found'};
  }
  let target;
  if (apps.length === 1) {
    target = apps[0];
  } else {
    const appName = opts.appName;
    if (appName == null) {
      return {
        ok: false,
        reason: `multiple application targets (${apps
          .map(a => a.name)
          .join(', ')}); pass --app-name to disambiguate`,
      };
    }
    target = apps.find(a => a.name === appName);
    if (target == null) {
      return {
        ok: false,
        reason: `no application target named "${appName}"`,
      };
    }
  }
  const configUuids = targetBuildConfigUuids(text, target);
  if (configUuids.length === 0) {
    return {ok: false, reason: 'could not resolve target build configurations'};
  }
  if (configUuids.some(c => configUsesPods(text, c))) {
    return {
      ok: false,
      reason:
        'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' +
        'supports SPM-only targets',
    };
  }
  // The target's own Frameworks build phase (where product build files link).
  const buildPhases = findField(text, target, 'buildPhases');
  const phaseUuids =
    buildPhases != null
      ? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? [])
      : [];
  let frameworksPhaseUuid = null;
  // Also capture the Sources phase — plugin generated sources compile into it
  // (see injectSpmIntoPbxproj step 8). Nullable: a target may legitimately
  // lack one, in which case generated-source wiring is skipped (not fatal).
  let sourcesPhaseUuid = null;
  for (const pu of phaseUuids) {
    const po = findObjectByUuid(text, pu);
    if (po == null) {
      continue;
    }
    const isa = findField(text, po, 'isa');
    if (isa == null) {
      continue;
    }
    if (
      frameworksPhaseUuid == null &&
      /PBXFrameworksBuildPhase/.test(isa.value)
    ) {
      frameworksPhaseUuid = pu;
    } else if (
      sourcesPhaseUuid == null &&
      /PBXSourcesBuildPhase/.test(isa.value)
    ) {
      sourcesPhaseUuid = pu;
    }
  }
  if (frameworksPhaseUuid == null) {
    return {ok: false, reason: 'target has no Frameworks build phase'};
  }
  return {
    ok: true,
    rootUuid: project.uuid,
    target,
    configUuids,
    frameworksPhaseUuid,
    sourcesPhaseUuid,
  };
}

/**
 * Splice the SPM dependency graph + React build settings + sync build phase
 * into `text` and return the modified pbxproj. Pure string transform (no I/O),
 * idempotent: objects already present (by UUID) and array members / settings
 * already applied are skipped, so a second run is a no-op.
 */
function injectSpmIntoPbxproj(
  input /*: string */,
  plan /*: {rootUuid: string, targetUuid: string, configUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */,
  reactNativePath /*: string */,
  remote /*: ?RemoteCfg */,
  generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
  flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
  scriptPhases /*: ReadonlyArray<PluginScriptPhase> */ = [],
) /*: {text: string, injectedUuids: Array<string>, createdArrayFields: Array<CreatedArrayField>, buildSettingChanges: Array<BuildSettingChange>, generatedSourceUuids: {[string]: Array<string>}, scriptPhaseUuids: {[string]: string}} */ {
  let text = input;
  const mkUuid = (section /*: string */, id /*: string */) =>
    namespacedUUID(plan.rootUuid, section, id);
  const graph = buildSpmDependencyGraph(mkUuid, remote);
  const entries = spmGraphToEntries(graph);
  const injectedUuids /*: Array<string> */ = [];

  // 1. Insert the new objects (skip any UUID already present — idempotency).
  const insertObjects = (
    sectionName /*: string */,
    objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */,
  ) => {
    const fresh = objs.filter(o => !text.includes(o.uuid));
    for (const o of objs) {
      injectedUuids.push(o.uuid);
    }
    if (fresh.length === 0) {
      return;
    }
    text = insertObjectsIntoSection(
      text,
      sectionName,
      fresh.map(serializeEntry).join('\n'),
    );
  };
  insertObjects('XCLocalSwiftPackageReference', entries.localRefs);
  if (entries.remoteRef != null) {
    insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]);
  }
  insertObjects('XCSwiftPackageProductDependency', entries.productDeps);
  insertObjects('PBXBuildFile', entries.buildFiles);

  const createdArrayFields /*: Array<CreatedArrayField> */ = [];

  // 2. packageReferences on the PBXProject.
  const pkgRefMembers = [
    ...(graph.remotePkgRef != null
      ? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}]
      : []),
    ...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})),
  ];
  const project = findProjectObject(text);
  if (project != null) {
    if (findField(text, project, 'packageReferences') == null) {
      createdArrayFields.push({container: 'project', key: 'packageReferences'});
    }
    text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers);
  }

  // 3. packageProductDependencies on the app target.
  const productMembers = graph.products.map(p => ({
    uuid: p.depUuid,
    comment: p.product,
  }));
  if (
    findField(
      text,
      findApplicationTargetByUuid(text, plan.targetUuid),
      'packageProductDependencies',
    ) == null
  ) {
    createdArrayFields.push({
      container: 'target',
      key: 'packageProductDependencies',
    });
  }
  text = addArrayMembers(
    text,
    findApplicationTargetByUuid(text, plan.targetUuid),
    'packageProductDependencies',
    productMembers,
  );

  // 4. product build files into the target's Frameworks phase.
  const phase = findObjectByUuid(text, plan.frameworksPhaseUuid);
  if (phase != null) {
    text = addArrayMembers(
      text,
      phase,
      'files',
      graph.products.map(p => ({
        uuid: p.buildFileUuid,
        comment: `${p.product} in Frameworks`,
      })),
    );
  }

  // 5. React build settings into every build config (Debug + Release).
  const buildSettingChanges /*: Array<BuildSettingChange> */ = [];
  for (const configUuid of plan.configUuids) {
    const merged = mergeReactBuildSettings(
      text,
      configUuid,
      buildConfigurationName(text, configUuid),
      reactNativePath,
      flavoredFrameworks,
    );
    text = merged.text;
    buildSettingChanges.push(merged.change);
  }

  // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action
  //    is what fires before SPM resolution). Prepended so it runs before
  //    Sources. We do NOT add a JS-bundle phase — an existing app already
  //    bundles JS via its own phase.
  const syncScript = buildSyncAutolinkingScript(reactNativePath);
  const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking');
  const syncEntry = shellScriptPhase(
    syncPhaseUuid,
    'Sync SPM Autolinking',
    syncScript,
  );
  if (!text.includes(syncPhaseUuid)) {
    text = insertObjectsIntoSection(
      text,
      'PBXShellScriptBuildPhase',
      serializeEntry(syncEntry),
    );
  } else {
    // Already injected on a prior run — the phase object owns these fields,
    // so refresh them in place (same quoting used at creation) in case the
    // generated script changed, or a project injected before shellPath moved
    // to bash (see shellScriptPhase) still has the stale value. Byte-identical
    // when neither did; field order and every other byte of the phase are
    // untouched.
    for (const key of ['shellScript', 'shellPath']) {
      const current = findObjectByUuid(text, syncPhaseUuid);
      if (current != null) {
        text = setScalarField(text, current, key, syncEntry.fields[key]);
      }
    }
  }
  injectedUuids.push(syncPhaseUuid);
  text = addArrayMembers(
    text,
    findApplicationTargetByUuid(text, plan.targetUuid),
    'buildPhases',
    [{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}],
    {prepend: true},
  );

  // 7. The sole writer of flavored frameworks under the final app bundle.
  //    SwiftPM owns only invariant header/source products, so no implicit SPM
  //    embed task competes with this phase.
  const embedPhaseUuid = mkUuid(
    'PBXShellScriptBuildPhase',
    'EmbedFlavoredFrameworks',
  );
  const embedScript = buildEmbedFrameworksScript(flavoredFrameworks);
  const embedInputs = [
    '$(SRCROOT)/build/xcframeworks/.artifact-stamp',
    ...flavoredFrameworks.map(
      framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`,
    ),
  ];
  const embedOutputs = flavoredFrameworks.map(
    framework =>
      `$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/${framework.frameworkName}.framework`,
  );
  const embedEntry = shellScriptPhase(
    embedPhaseUuid,
    'Embed React Native Flavored Frameworks',
    embedScript,
    {
      inputPaths: pbxPathList(embedInputs),
      outputPaths: pbxPathList(embedOutputs),
    },
  );
  if (!text.includes(embedPhaseUuid)) {
    text = insertObjectsIntoSection(
      text,
      'PBXShellScriptBuildPhase',
      serializeEntry(embedEntry),
    );
  } else {
    const existingPhase = findObjectByUuid(text, embedPhaseUuid);
    if (existingPhase != null) {
      for (const key of [
        'shellScript',
        'inputPaths',
        'outputPaths',
        'shellPath',
      ]) {
        const current = findObjectByUuid(text, embedPhaseUuid);
        if (current != null) {
          text = setScalarField(text, current, key, embedEntry.fields[key]);
        }
      }
    }
  }
  injectedUuids.push(embedPhaseUuid);
  text = addBuildPhaseAfter(
    text,
    findApplicationTargetByUuid(text, plan.targetUuid),
    plan.frameworksPhaseUuid,
    {
      uuid: embedPhaseUuid,
      comment: 'Embed React Native Flavored Frameworks',
    },
  );

  // 8. Plugin generated sources compiled INTO THE APP TARGET (e.g. Expo's
  //    ExpoModulesProvider.swift). An `@objc` class only reaches the ObjC
  //    classlist — required for NSClassFromString discovery — when it compiles
  //    into the app target, not the static Autolinked aggregate. Each source
  //    gets a PBXFileReference + PBXBuildFile + a Sources-phase entry, parented
  //    under a single "SPM Generated Sources" group. Every UUID is keyed on the
  //    normalized path (deterministic → idempotent) and recorded so `deinit`
  //    reverts it and `update` reconciles it (removal is done by the caller,
  //    which owns the prior marker; emission here is purely additive).
  const generatedSourceUuids /*: {[string]: Array<string>} */ = {};
  const sourcesPhaseUuid = plan.sourcesPhaseUuid;
  if (generatedSources.length > 0) {
    if (sourcesPhaseUuid == null) {
      log(
        'warning: the app target has no Sources build phase — cannot compile ' +
          `${generatedSources.length} SPM plugin generated source(s) into the ` +
          'app target; skipping. Any @objc classes they define will not be ' +
          'discoverable via NSClassFromString.',
      );
    } else {
      const fileRefs = [];
      const buildFiles = [];
      const sourcesMembers = [];
      const groupChildren = [];
      for (const src of generatedSources) {
        const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`);
        const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`);
        generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid];
        // The name/path VALUES stay verbatim (escaped) — only the cosmetic
        // comments are normalized, falling back to the normalized path (the
        // ledger key) and then to no comment at all, which is well-formed.
        const label = commentSafe(src.name) || commentSafe(src.path);
        const inSources = label === '' ? '' : `${label} in Sources`;
        fileRefs.push({
          uuid: fileRefUuid,
          comment: label,
          fields: {
            isa: 'PBXFileReference',
            lastKnownFileType: src.fileType,
            name: quoteIfNeeded(src.name),
            path: quoteIfNeeded(src.path),
            sourceTree: src.sourceTree,
          },
        });
        buildFiles.push({
          uuid: buildFileUuid,
          comment: inSources,
          fields: {
            isa: 'PBXBuildFile',
            fileRef: `${fileRefUuid}${uuidComment(label)}`,
          },
        });
        sourcesMembers.push({uuid: buildFileUuid, comment: inSources});
        groupChildren.push({uuid: fileRefUuid, comment: label});
      }
      insertObjects('PBXFileReference', fileRefs);
      insertObjects('PBXBuildFile', buildFiles);

      // Compile membership — the actual reason these are wired into the app.
      const sourcesPhase = findObjectByUuid(text, sourcesPhaseUuid);
      if (sourcesPhase != null) {
        text = addArrayMembers(text, sourcesPhase, 'files', sourcesMembers);
      }

      // The "SPM Generated Sources" group (created on first use, then reused).
      // Insert with empty children so the ONE population path (addArrayMembers)
      // handles both create and reconcile, keeping formatting identical.
      const groupUuid = mkUuid('PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID);
      if (!text.includes(groupUuid)) {
        text = insertObjectsIntoSection(
          text,
          'PBXGroup',
          serializeEntry({
            uuid: groupUuid,
            comment: SPM_GENERATED_SOURCES_GROUP_NAME,
            fields: {
              isa: 'PBXGroup',
              children: '(\n\t\t\t)',
              name: quoteIfNeeded(SPM_GENERATED_SOURCES_GROUP_NAME),
              sourceTree: '"<group>"',
            },
          }),
        );
      }
      injectedUuids.push(groupUuid);
      const groupObj = findObjectByUuid(text, groupUuid);
      if (groupObj != null) {
        text = addArrayMembers(text, groupObj, 'children', groupChildren);
      }

      // Parent the group under the project's main group (idempotent). Appends
      // to a pre-existing children array, so no createdArrayField is recorded —
      // deinit removes the group's membership via removeArrayMembersByUuid and
      // the group object itself via removeObjectByUuid (groupUuid is injected).
      const proj = findProjectObject(text);
      const mainGroupField =
        proj != null ? findField(text, proj, 'mainGroup') : null;
      const mainGroupMatch =
        mainGroupField != null
          ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/)
          : null;
      const mainGroupObj =
        mainGroupMatch != null
          ? findObjectByUuid(text, mainGroupMatch[0])
          : null;
      if (mainGroupObj != null) {
        text = addArrayMembers(text, mainGroupObj, 'children', [
          {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME},
        ]);
      }
    }
  }

  // 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each
  //    phase's UUID is keyed on its plugin id, so re-runs refresh in place and
  //    `deinit` reverses it.
  const scriptPhaseUuids /*: {[string]: string} */ = {};
  if (scriptPhases.length > 0) {
    const seated /*: Array<SeatedPhase> */ = [];
    for (const scriptPhase of scriptPhases) {
      const uuid = mkUuid(
        'PBXShellScriptBuildPhase',
        `plugin:${scriptPhase.id}`,
      );
      scriptPhaseUuids[scriptPhase.id] = uuid;
      // The full name goes into the `name` field (escaped) — that is what Xcode
      // displays. The cosmetic comments get it normalized, falling back to the
      // normalized id and then to no comment at all, which is well-formed. The
      // fallback is sanitized rather than trusted to the id charset, so widening
      // that charset can never reach a comment.
      const comment =
        commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id);
      const entry = shellScriptPhase(
        uuid,
        scriptPhase.name,
        scriptPhase.script,
        {
          inputPaths: scriptPhase.inputPaths,
          outputPaths: scriptPhase.outputPaths,
          alwaysOutOfDate: scriptPhase.alwaysOutOfDate,
          comment,
        },
      );
      if (!text.includes(uuid)) {
        text = insertObjectsIntoSection(
          text,
          'PBXShellScriptBuildPhase',
          serializeEntry(entry),
        );
      } else {
        // Rewrite the fields we own unconditionally — byte-identical when the
        // plugin's declaration hasn't changed, so no content hashing is needed.
        // Each write shifts offsets, hence the re-lookup per field.
        for (const key of [
          'name',
          'shellScript',
          'inputPaths',
          'outputPaths',
          'shellPath',
        ]) {
          const current = findObjectByUuid(text, uuid);
          if (current != null) {
            text = setScalarField(text, current, key, entry.fields[key]);
          }
        }
        // Flipping alwaysOutOfDate back off means REMOVING the field —
        // setScalarField would only ever write a value.
        const current = findObjectByUuid(text, uuid);
        if (current != null) {
          text =
            scriptPhase.alwaysOutOfDate === true
              ? setScalarField(text, current, 'alwaysOutOfDate', '1')
              : removeField(text, current, 'alwaysOutOfDate');
        }
      }
      injectedUuids.push(uuid);
      text = setUuidComment(text, uuid, comment);
      seated.push({uuid, comment, position: scriptPhase.position});
    }
    text = seatScriptPhases(
      text,
      plan.targetUuid,
      syncPhaseUuid,
      seated,
      sourcesPhaseUuid,
    );
  }

  return {
    text,
    injectedUuids,
    createdArrayFields,
    buildSettingChanges,
    generatedSourceUuids,
    scriptPhaseUuids,
  };
}

/** Re-locate an application target by UUID against the current text. */
function findApplicationTargetByUuid(
  text /*: string */,
  targetUuid /*: string */,
) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ {
  const obj = findObjectByUuid(text, targetUuid);
  if (obj == null) {
    throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`);
  }
  return obj;
}

/** Strip the surrounding plist quotes from a build-setting token, if any. */
function unquotePlist(s /*: string */) /*: string */ {
  return s.replace(/^"/, '').replace(/"$/, '');
}

/**
 * The individual values a build setting already carries, unquoted — for both
 * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting
 * (`("$(inherited)", DEBUG)`) and the scalar form the app template and
 * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the
 * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently
 * skip the injection.
 */
function buildSettingValueTokens(value /*: string */) /*: Set<string> */ {
  return new Set(
    value
      .split(/[\s,()]+/)
      .filter(Boolean)
      .map(unquotePlist),
  );
}

/**
 * Merge the React build settings into one XCBuildConfiguration's dict. Returns
 * the modified text plus a precise record of what was actually added — so
 * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching
 * a value the user already had (key insight: ensureScalarField/
 * addArrayStringValues are no-ops / dedupe when a value is already present).
 */
function mergeReactBuildSettings(
  input /*: string */,
  configUuid /*: string */,
  configurationName /*: string */,
  reactNativePath /*: string */,
  flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
) /*: {text: string, change: BuildSettingChange} */ {
  let text = input;
  const scalars = [
    {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'},
    {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)},
  ];
  // Re-locate the buildSettings dict before each edit (offsets shift).
  const dict = () => {
    const cfg = findObjectByUuid(text, configUuid);
    if (cfg == null) {
      return null;
    }
    const bs = findField(text, cfg, 'buildSettings');
    if (bs == null) {
      return null;
    }
    return {
      uuid: configUuid,
      bodyOpen: bs.valueStart,
      bodyClose: bs.tokenEnd - 1,
    };
  };
  const createdArrayKeys /*: Array<string> */ = [];
  const appendedArrayValues /*: {[string]: Array<string>} */ = {};
  const promotedArrayScalars /*: {[string]: string} */ = {};
  const createdScalars /*: Array<string> */ = [];
  const arraySettings = [
    ...INJECTED_ARRAY_SETTINGS,
    ...(flavorForBuildConfiguration(configurationName) === 'debug'
      ? DEBUG_ARRAY_SETTINGS
      : []),
    ...frameworkArrayBuildSettings(flavoredFrameworks),
  ];
  for (const {key, values} of arraySettings) {
    const d = dict();
    if (d == null) {
      continue;
    }
    const existing = findField(text, d, key);
    // Non-null only for a scalar addArrayStringValues would promote to an array
    // (same array-vs-scalar test it uses). Kept RAW: findField's token for a
    // bare scalar ends at the `;`, so it carries any whitespace before it, and
    // deinit has to write those bytes back verbatim.
    const priorScalar =
      existing != null && !existing.value.trimStart().startsWith('(')
        ? existing.value
        : null;
    if (existing == null) {
      createdArrayKeys.push(key);
    } else {
      const present = buildSettingValueTokens(existing.value);
      const fresh = values.filter(v => !present.has(unquotePlist(v)));
      if (fresh.length === 0) {
        // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by
        // EXACT array member, so a value the user carries in the scalar form
        // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would
        // otherwise be promoted to an array and re-appended — an edit `deinit`
        // has no record of and so could never reverse.
        continue;
      }
      if (priorScalar == null) {
        appendedArrayValues[key] = fresh;
      }
    }
    const beforeAdd = text;
    text = addArrayStringValues(text, d, key, values);
    // Record only a promotion that actually happened: addArrayStringValues
    // no-ops when `values` is empty or every value is already a member, and a
    // recorded-but-untouched field would have deinit clobber whatever the user
    // has there by then. Restoring the scalar subsumes removing the injected
    // members, so the two records stay mutually exclusive per key.
    if (priorScalar != null && text !== beforeAdd) {
      promotedArrayScalars[key] = priorScalar;
    }
  }
  const replacedScalars /*: {[string]: string} */ = {};
  for (const {key, value} of scalars) {
    const d = dict();
    if (d == null) {
      continue;
    }
    const existing = findField(text, d, key);
    if (existing == null) {
      createdScalars.push(key);
    } else if (
      key === 'REACT_NATIVE_PATH' &&
      existing.value.includes('PODS_ROOT')
    ) {
      // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template
      // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves
      // empty at build time, so the Bundle React Native code and images
      // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace
      // it with the SPM-computed path, recording the original for deinit.
      replacedScalars[key] = existing.value;
      text = removeField(text, d, key);
      const d2 = dict();
      if (d2 == null) {
        continue;
      }
      text = ensureScalarField(text, d2, key, value);
      continue;
    }
    text = ensureScalarField(text, d, key, value);
  }
  const ownedScalars = [
    {
      key: 'RN_SPM_FLAVOR',
      value: flavorForBuildConfiguration(configurationName),
    },
    ...frameworkConditionalSettings(flavoredFrameworks),
  ];
  for (const {key, value} of ownedScalars) {
    const d = dict();
    if (d == null) {
      continue;
    }
    const existing = findField(text, d, key);
    if (existing == null) {
      createdScalars.push(key);
    } else if (existing.value !== value) {
      replacedScalars[key] = existing.value;
    }
    text = setScalarField(text, d, key, value);
  }
  return {
    text,
    change: {
      configUuid,
      createdArrayKeys,
      appendedArrayValues,
      createdScalars,
      replacedScalars,
      ...(Object.keys(promotedArrayScalars).length > 0
        ? {promotedArrayScalars}
        : {}),
    },
  };
}

// Write only when content changed (avoids spurious Xcode reloads / git churn).
function writeIfChanged(
  filePath /*: string */,
  content /*: string */,
) /*: boolean */ {
  fs.mkdirSync(path.dirname(filePath), {recursive: true});
  try {
    if (fs.readFileSync(filePath, 'utf8') === content) {
      return false;
    }
  } catch {
    /* file doesn't exist yet */
  }
  fs.writeFileSync(filePath, content, 'utf8');
  return true;
}

/**
 * Add the "Sync SPM Autolinking" pre-action to an existing scheme's
 * BuildAction, reusing the scheme's own primary BuildableReference. Returns
 * the XML unchanged when the pre-action is already present.
 */
function addPreActionToScheme(
  xml /*: string */,
  targetUuid /*: string */,
  syncScript /*: string */,
) /*: string */ {
  const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
  if (titleIdx >= 0) {
    // Already injected on a prior run — refresh a possibly-stale scriptText
    // in place (same escaping used at creation) rather than leaving it
    // forever. Splice by index (not a regex/string replace) since the script
    // itself may contain `$`-sequences that String.replace's replacement-
    // pattern syntax would otherwise misinterpret. Byte-identical when the
    // script is unchanged; every other byte of the scheme is untouched.
    const scriptTextMarker = 'scriptText = "';
    const stIdx = xml.indexOf(scriptTextMarker, titleIdx);
    if (stIdx < 0) {
      return xml; // malformed — leave untouched rather than guess
    }
    const valueStart = stIdx + scriptTextMarker.length;
    // escapeXmlAttribute maps a literal `"` to `&quot;`, so the attribute
    // value itself never contains one — the next `"` is always the closing
    // delimiter.
    const valueEnd = xml.indexOf('"', valueStart);
    let xmlWithScript =
      xml.slice(0, valueStart) +
      escapeXmlAttribute(syncScript) +
      xml.slice(valueEnd);

    // Xcode always runs a scheme pre-action's scriptText under the shell this
    // attribute names (default /bin/sh), independent of a
    // PBXShellScriptBuildPhase's own shellPath (see shellScriptPhase) — pin
    // it to bash too. `>` can't appear unescaped inside either attribute
    // value, so it reliably closes the ActionContent open tag. Search the
    // whole open tag (not just after scriptText) and allow any spacing
    // around `=`, since attribute order and formatting aren't guaranteed.
    const contentOpenIdx = xmlWithScript.lastIndexOf(
      '<ActionContent',
      titleIdx,
    );
    const contentCloseIdx = xmlWithScript.indexOf('>', stIdx);
    const openTag = xmlWithScript.slice(contentOpenIdx, contentCloseIdx);
    const stiMatch = openTag.match(/shellToInvoke\s*=\s*"/);
    if (stiMatch != null) {
      const stiValueStart =
        contentOpenIdx + stiMatch.index + stiMatch[0].length;
      const stiValueEnd = xmlWithScript.indexOf('"', stiValueStart);
      xmlWithScript =
        xmlWithScript.slice(0, stiValueStart) +
        '/bin/bash' +
        xmlWithScript.slice(stiValueEnd);
    } else {
      xmlWithScript =
        xmlWithScript.slice(0, contentCloseIdx) +
        '\n               shellToInvoke = "/bin/bash"' +
        xmlWithScript.slice(contentCloseIdx);
    }
    return xmlWithScript;
  }
  const refMatch = xml.match(
    new RegExp(
      `<BuildableReference\\b[^>]*BlueprintIdentifier = "${targetUuid}"[^>]*>`,
    ),
  );
  const attr = (name /*: string */) => {
    const m =
      refMatch != null
        ? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`))
        : null;
    return m != null ? m[1] : '';
  };
  const cleanRef =
    `<BuildableReference\n` +
    `                     BuildableIdentifier = "primary"\n` +
    `                     BlueprintIdentifier = "${targetUuid}"\n` +
    `                     BuildableName = "${attr('BuildableName')}"\n` +
    `                     BlueprintName = "${attr('BlueprintName')}"\n` +
    `                     ReferencedContainer = "${attr('ReferencedContainer')}">\n` +
    `                  </BuildableReference>`;
  const executionAction =
    `         <ExecutionAction\n` +
    `            ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">\n` +
    `            <ActionContent\n` +
    `               title = "Sync SPM Autolinking"\n` +
    `               scriptText = "${escapeXmlAttribute(syncScript)}"\n` +
    `               shellToInvoke = "/bin/bash">\n` +
    `               <EnvironmentBuildable>\n` +
    `                  ${cleanRef}\n` +
    `               </EnvironmentBuildable>\n` +
    `            </ActionContent>\n` +
    `         </ExecutionAction>`;

  if (/<PreActions>/.test(xml)) {
    return xml.replace(
      '</PreActions>',
      `${executionAction}\n      </PreActions>`,
    );
  }
  const openEnd = xml.indexOf('>', xml.indexOf('<BuildAction'));
  if (openEnd < 0) {
    return xml; // no BuildAction — leave the scheme untouched
  }
  const block = `\n      <PreActions>\n${executionAction}\n      </PreActions>`;
  return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1);
}

/**
 * Ensure the app target's shared scheme runs the sync pre-action before SPM
 * resolution. Updates the scheme that builds the target if one exists,
 * otherwise creates a fresh shared scheme. Returns 'updated' | 'created' |
 * 'unchanged'.
 */
function injectOrCreateScheme(
  xcodeprojDir /*: string */,
  opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */,
) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ {
  const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes');
  let schemeFiles /*: Array<string> */ = [];
  try {
    schemeFiles = fs
      .readdirSync(schemesDir)
      .filter(f => f.endsWith('.xcscheme'));
  } catch {
    /* no shared schemes dir yet */
  }
  for (const f of schemeFiles) {
    const p = path.join(schemesDir, f);
    const xml = fs.readFileSync(p, 'utf8');
    if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) {
      const updated = addPreActionToScheme(
        xml,
        opts.targetUuid,
        opts.syncScript,
      );
      return {
        status: writeIfChanged(p, updated) ? 'updated' : 'unchanged',
        file: f,
      };
    }
  }
  const file = `${opts.appName}.xcscheme`;
  const xml = generateXcscheme(
    opts.appName,
    opts.targetUuid,
    opts.projName,
    opts.syncScript,
  );
  writeIfChanged(path.join(schemesDir, file), xml);
  return {status: 'created', file};
}

/**
 * Strip the empty `Pods` group `pod deintegrate` leaves in the navigator.
 * Called by `add --deintegrate` after deintegration so the converted project is
 * visually clean. No-op when absent or when the group still has children.
 */
function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ {
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
  if (!fs.existsSync(pbxprojPath)) {
    return false;
  }
  const original = fs.readFileSync(pbxprojPath, 'utf8');
  const cleaned = removeEmptyPodsGroup(original);
  return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
}

/**
 * Strip the dangling `JavaScriptCore.framework` file reference the community
 * template has carried since RN 0.60 (navigator-only, meaningless under
 * Hermes) — see `removeDanglingJavaScriptCoreRef` for the full rationale and
 * the safety gate that leaves a still-linked reference untouched. No-op when
 * absent or when the pbxproj is missing.
 */
function cleanupDanglingJavaScriptCoreRef(
  xcodeprojPath /*: string */,
) /*: boolean */ {
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
  if (!fs.existsSync(pbxprojPath)) {
    return false;
  }
  const original = fs.readFileSync(pbxprojPath, 'utf8');
  const cleaned = removeDanglingJavaScriptCoreRef(original);
  return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false;
}

/**
 * Normalize one plugin generated-source path into the fields a PBXFileReference
 * needs. Stores an SRCROOT-relative path (`sourceTree = SOURCE_ROOT`) when the
 * source lives under the app root — the typical case (build/generated/…) — and
 * an absolute path (`sourceTree = "<absolute>"`) otherwise. Returns null (with a
 * loud log) for an extension the pbxproj can't compile.
 */
function normalizeGeneratedSource(
  appRoot /*: string */,
  srcPath /*: string */,
) /*: ?GeneratedSource */ {
  const ext = path.extname(srcPath).toLowerCase();
  const fileType = GENERATED_SOURCE_FILE_TYPES[ext];
  if (fileType == null) {
    log(
      `warning: unsupported generated-source extension "${ext}" for ` +
        `${srcPath}; skipping (SPM plugin sources must be .swift/.m/.mm).`,
    );
    return null;
  }
  const abs = path.isAbsolute(srcPath)
    ? srcPath
    : path.resolve(appRoot, srcPath);
  const rel = path.relative(appRoot, abs);
  const underAppRoot =
    rel !== '' &&
    rel !== '..' &&
    !rel.startsWith('..' + path.sep) &&
    !path.isAbsolute(rel);
  return {
    path: underAppRoot ? rel : abs,
    name: path.basename(abs),
    sourceTree: underAppRoot ? 'SOURCE_ROOT' : '"<absolute>"',
    fileType,
  };
}

/**
 * Read + normalize the plugin generated-sources manifest at
 * `<appRoot>/build/generated/autolinking/.spm-plugin-generated-sources.json`.
 * Absent, empty, or malformed → `[]` (the feature stays inert for non-plugin
 * apps and never breaks injection). The file need not exist yet at inject time:
 * the build-time sync regenerates it before compile, and a PBXFileReference to a
 * not-yet-created path is valid.
 */
function readGeneratedSourcesManifest(
  appRoot /*: string */,
) /*: Array<GeneratedSource> */ {
  const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST);
  let raw /*: string */ = '';
  try {
    raw = fs.readFileSync(manifestPath, 'utf8');
  } catch {
    return [];
  }
  let entries /*: unknown */ = null;
  try {
    entries = JSON.parse(raw);
  } catch {
    log(
      `warning: could not parse ${SPM_GENERATED_SOURCES_MANIFEST}; ` +
        'skipping generated sources.',
    );
    return [];
  }
  if (!Array.isArray(entries)) {
    return [];
  }
  const out /*: Array<GeneratedSource> */ = [];
  for (const entry of entries) {
    if (
      entry == null ||
      typeof entry !== 'object' ||
      typeof entry.path !== 'string'
    ) {
      continue;
    }
    const normalized = normalizeGeneratedSource(appRoot, entry.path);
    // Dedupe by normalized path — a duplicate manifest entry would otherwise
    // double-insert identical-UUID pbxproj objects.
    if (normalized != null && !out.some(s => s.path === normalized.path)) {
      out.push(normalized);
    }
  }
  return out;
}

const nonEmptyStrings = (value /*: unknown */) /*: ?Array<string> */ =>
  Array.isArray(value)
    ? value.filter(p => typeof p === 'string' && p.length > 0)
    : null;

/**
 * Read the plugin script-phases manifest at
 * `<appRoot>/build/generated/autolinking/.spm-plugin-script-phases.json`.
 * Absent, unparseable, or malformed → `[]`. Lenient by design even though the
 * plugin contract validates these entries fatally at invoke time: the sidecar
 * legitimately does not exist yet on a first `spm add`, and a stale or
 * hand-edited file must not break injection. This reader is the ONLY gate on
 * such a file, so it applies the same id/name rules (from spm-utils) that
 * invokePlugins enforces fatally.
 */
function readScriptPhasesManifest(
  appRoot /*: string */,
) /*: Array<PluginScriptPhase> */ {
  const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST);
  let raw /*: string */ = '';
  try {
    raw = fs.readFileSync(manifestPath, 'utf8');
  } catch {
    return [];
  }
  let entries /*: unknown */ = null;
  try {
    entries = JSON.parse(raw);
  } catch {
    log(
      `warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` +
        'skipping script phases.',
    );
    return [];
  }
  if (!Array.isArray(entries)) {
    return [];
  }
  const out /*: Array<PluginScriptPhase> */ = [];
  for (const entry of entries) {
    if (
      entry == null ||
      typeof entry !== 'object' ||
      !isValidScriptPhaseId(entry.id) ||
      !isValidScriptPhaseName(entry.name) ||
      typeof entry.script !== 'string' ||
      entry.script.length === 0 ||
      // An unknown position is a malformed entry, not something to coerce: it
      // would silently run somewhere the plugin didn't ask for.
      (entry.position != null &&
        entry.position !== 'beforeCompile' &&
        entry.position !== 'end') ||
      // Dedupe by id — the id seeds the phase's UUID, so a duplicate would
      // insert two objects with identical UUIDs.
      out.some(phase => phase.id === entry.id)
    ) {
      continue;
    }
    const phase /*: PluginScriptPhase */ = {
      id: entry.id,
      name: entry.name,
      script: entry.script,
      position: entry.position ?? 'end',
    };
    const inputPaths = nonEmptyStrings(entry.inputPaths);
    const outputPaths = nonEmptyStrings(entry.outputPaths);
    if (inputPaths != null) {
      phase.inputPaths = inputPaths;
    }
    if (outputPaths != null) {
      phase.outputPaths = outputPaths;
    }
    if (typeof entry.alwaysOutOfDate === 'boolean') {
      phase.alwaysOutOfDate = entry.alwaysOutOfDate;
    }
    out.push(phase);
  }
  return out;
}

/**
 * Read the `.spm-injected.json` marker of a previously-injected project, or
 * null when absent/unreadable. Used to reconcile generated sources on `update`
 * and to read back a pinned `artifactsVersionOverride` (see below).
 */
function readMarker(
  xcodeprojPath /*: string */,
) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array<string>}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>, buildSettingChanges?: Array<BuildSettingChange>, createdArrayFields?: Array<CreatedArrayField>, scheme?: {file?: ?string, created?: ?boolean}, ...} */ {
  const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
  try {
    // $FlowFixMe[incompatible-return] JSON.parse returns any
    return JSON.parse(fs.readFileSync(markerPath, 'utf8'));
  } catch {
    return null;
  }
}

// Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
// marker (the user-owned project SPM packages were injected into in place),
// or null when none has been injected yet. Pure fs reads — safe for the
// marker readers below, and for callers that only locate the project (setup-
// apple-spm.js's action defaulting and `deinit`), to call without exercising
// any pbxproj-editing machinery.
function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
  let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
  try {
    // $FlowFixMe[incompatible-type] Dirent typing
    entries = fs.readdirSync(appRoot, {withFileTypes: true});
  } catch {
    return null;
  }
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs
    const name /*: string */ = entry.name;
    if (!name.endsWith('.xcodeproj')) continue;
    if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) {
      return path.join(appRoot, name);
    }
  }
  return null;
}

/**
 * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm
 * update --version` pinned into the injected xcodeproj's `.spm-injected.json`
 * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
 * below), or null when no project is injected yet, no override is pinned, or
 * the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
 * determineVersion prefers the pinned version over the one derived from
 * node_modules/react-native/package.json, so a later flagless `add`/`update`
 * (and `download`) stays on the SAME artifact slot the explicit `--version`
 * selected.
 */
function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
  const xcodeprojPath = findInjectedXcodeproj(appRoot);
  if (xcodeprojPath == null) {
    return null;
  }
  const override = readMarker(xcodeprojPath)?.artifactsVersionOverride;
  return typeof override === 'string' && override.length > 0 ? override : null;
}

/**
 * Read the autolinking config command a previous `spm add`/`update` pinned into
 * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing
 * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but
 * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it
 * on add/update/scaffold and on the build-time `sync`. Re-validated through the
 * same parseConfigCommandJson the flag goes through, and never throws, so a
 * hand-edited marker degrades to the env-var/default command instead of
 * injecting a bogus argv into the build.
 */
function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array<string> */ {
  const xcodeprojPath = findInjectedXcodeproj(appRoot);
  if (xcodeprojPath == null) {
    return null;
  }
  const pinned = readMarker(xcodeprojPath)?.configCommand;
  if (pinned == null) {
    return null;
  }
  try {
    return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER);
  } catch {
    return null;
  }
}

/**
 * Union the array fields RN has created across syncs, deduped by container+key.
 *
 * THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the
 * marker's `scheme.created` carries it forward for the same reason): a re-sync
 * re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds
 * whatever the first run created already there and reports creating nothing. Once
 * RN has created something, that fact has to be carried forward, or the new
 * marker forgets it and `deinit` leaves an empty `packageReferences` /
 * `packageProductDependencies` behind and the generated scheme on disk.
 *
 * The record licenses removal; it does not order it. `deinit` still checks that
 * what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so
 * carrying forward a field the user has since taken over — or deleted by hand —
 * is safe.
 */
function mergeCreatedArrayFields(
  previous /*: ReadonlyArray<CreatedArrayField> */,
  current /*: ReadonlyArray<CreatedArrayField> */,
) /*: Array<CreatedArrayField> */ {
  const merged = [...previous];
  for (const field of current) {
    if (
      !merged.some(
        seen => seen.container === field.container && seen.key === field.key,
      )
    ) {
      merged.push(field);
    }
  }
  return merged;
}

/**
 * Add SPM packages to a user's EXISTING xcodeproj in place. Returns
 * {status: 'injected', target} on success, or {status: 'refused', reason}
 * when the project can't be safely edited (caller surfaces it; fail-loud).
 */
function injectSpmIntoExistingXcodeproj(
  opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>} */,
) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ {
  const {appRoot, reactNativeRoot, xcodeprojPath} = opts;
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
  if (!fs.existsSync(pbxprojPath)) {
    return {
      status: 'refused',
      reason: `no project.pbxproj at ${xcodeprojPath}`,
    };
  }
  const original = fs.readFileSync(pbxprojPath, 'utf8');
  const plan = planInjection(original, {appName: opts.appName});
  if (!plan.ok) {
    return {status: 'refused', reason: plan.reason};
  }
  const reactNativePath = path.relative(appRoot, reactNativeRoot);
  const remote = remotePackageConfig(appRoot);
  const generatedSources = readGeneratedSourcesManifest(appRoot);
  const scriptPhases = readScriptPhasesManifest(appRoot);
  const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;

  const prevMarker = readMarker(xcodeprojPath);

  // Reconcile generated sources injected on a PRIOR run that are no longer in
  // the manifest (a plugin's entry was dropped, or the plugin was removed).
  // Diff the marker's `generatedSources` map against the current manifest and
  // delete only the stale UUIDs — the additive injection below re-emits (and
  // idempotently skips) everything that remains, so an unchanged run stays
  // byte-identical. deinit needs none of this: the removed objects live in
  // `injectedUuids`.
  const prevGeneratedSources /*: {[string]: Array<string>} */ =
    prevMarker?.generatedSources ?? {};
  const currentPaths = new Set(generatedSources.map(s => s.path));
  const staleUuids /*: Array<string> */ = [];
  for (const p of Object.keys(prevGeneratedSources)) {
    if (!currentPaths.has(p)) {
      staleUuids.push(...prevGeneratedSources[p]);
    }
  }
  // When the last generated source is gone, retire the now-empty group too.
  if (
    generatedSources.length === 0 &&
    Object.keys(prevGeneratedSources).length > 0
  ) {
    staleUuids.push(
      namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID),
    );
  }
  // Same reconciliation for script phases, keyed on the plugin-supplied id.
  const prevScriptPhases /*: {[string]: string} */ =
    prevMarker?.scriptPhases ?? {};
  const currentPhaseIds = new Set(scriptPhases.map(p => p.id));
  for (const id of Object.keys(prevScriptPhases)) {
    if (!currentPhaseIds.has(id)) {
      staleUuids.push(prevScriptPhases[id]);
    }
  }
  // Re-apply generated settings from a clean recorded baseline. This removes
  // linker entries for plugin frameworks that disappeared and keeps the new
  // marker a complete inverse after an idempotent update.
  let base = removeRecordedBuildSettings(
    original,
    prevMarker?.buildSettingChanges ?? [],
  );
  if (staleUuids.length > 0) {
    base = removeArrayMembersByUuid(base, staleUuids);
    for (const u of staleUuids) {
      base = removeObjectByUuid(base, u);
    }
  }

  const {
    text,
    injectedUuids,
    createdArrayFields,
    buildSettingChanges,
    generatedSourceUuids,
    scriptPhaseUuids,
  } = injectSpmIntoPbxproj(
    base,
    {
      rootUuid: plan.rootUuid,
      targetUuid: plan.target.uuid,
      configUuids: plan.configUuids,
      frameworksPhaseUuid: plan.frameworksPhaseUuid,
      sourcesPhaseUuid: plan.sourcesPhaseUuid,
    },
    reactNativePath,
    remote,
    generatedSources,
    flavoredFrameworks,
    scriptPhases,
  );

  const changed = writeIfChanged(pbxprojPath, text);
  log(
    changed
      ? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}`
      : `${path.relative(appRoot, pbxprojPath)} already up to date`,
  );

  const projName = path.basename(xcodeprojPath, '.xcodeproj');
  const schemeResult = injectOrCreateScheme(xcodeprojPath, {
    appName: plan.target.name,
    targetUuid: plan.target.uuid,
    projName,
    // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap
    // could win its race and mask a mismatch from the in-target detector.
    syncScript: buildSchemePreActionScript(reactNativePath),
  });
  log(`Scheme sync pre-action: ${schemeResult.status}`);

  // The RN version this app's xcframework artifact-cache slot should be
  // pinned to, when `add`/`update` was given an EXPLICIT `--version` — SETS
  // the pin. Omitting `--version` (opts.artifactsVersionOverride is null)
  // PRESERVES whatever was recorded on a prior run, since it's an
  // intentional pin, not something to silently re-derive from
  // node_modules/react-native/package.json. There is no "clear" verb yet;
  // `deinit` (removeSpmInjection) drops the whole marker, including this
  // field. Read back by readArtifactsVersionOverride (above) so a later
  // flagless `add`/`update`/`download` resolves to the SAME slot, even on a
  // version-mismatched setup.
  const artifactsVersionOverride =
    opts.artifactsVersionOverride ??
    prevMarker?.artifactsVersionOverride ??
    null;

  // Same set-or-preserve contract as the version pin above, for the autolinking
  // config command `add`/`update` resolved (`--config-command` or
  // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the
  // flag nor the developer's shell environment, so without the pin it
  // re-derives autolinking.json with the default @react-native-community/cli
  // command and breaks apps that replace it. Read back by
  // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops
  // the whole marker, this field with it.
  const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null;

  // Marker: idempotency signal + the exact, reversible record of every edit so
  // `deinit` (removeSpmInjection) can undo precisely what was added.
  writeIfChanged(
    path.join(xcodeprojPath, SPM_INJECTED_MARKER),
    JSON.stringify(
      {
        rootUuid: plan.rootUuid,
        target: plan.target.name,
        targetUuid: plan.target.uuid,
        injectedUuids: Array.from(new Set(injectedUuids)).sort(),
        createdArrayFields: mergeCreatedArrayFields(
          prevMarker?.createdArrayFields ?? [],
          createdArrayFields,
        ),
        buildSettingChanges,
        // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next
        // `update` to reconcile away entries that left the manifest.
        generatedSources: generatedSourceUuids,
        // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the
        // same way.
        scriptPhases: scriptPhaseUuids,
        artifactsVersionOverride,
        configCommand,
        scheme: {
          file: schemeResult.file,
          // Sticky — see mergeCreatedArrayFields for why a later sync cannot
          // observe this for itself.
          created:
            schemeResult.status === 'created' ||
            prevMarker?.scheme?.created === true,
        },
      },
      null,
      2,
    ) + '\n',
  );

  ensureStubPackages(appRoot);
  return {status: 'injected', target: plan.target.name};
}

/** The sync pre-action's script, unescaped, or null when the scheme has none. */
function schemePreActionScript(xml /*: string */) /*: ?string */ {
  const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
  if (titleIdx < 0) {
    return null;
  }
  const marker = 'scriptText = "';
  const start = xml.indexOf(marker, titleIdx);
  if (start < 0) {
    return null;
  }
  const valueStart = start + marker.length;
  // escapeXmlAttribute maps a literal `"` to `&quot;`, so the next `"` is always
  // the closing delimiter.
  const valueEnd = xml.indexOf('"', valueStart);
  return valueEnd < 0
    ? null
    : unescapeXmlAttribute(xml.slice(valueStart, valueEnd));
}

/**
 * Whether `xml` is still, byte for byte, the scheme RN generates for this target
 * — everything but the pre-action's script, which RN rewrites in place on every
 * sync and which varies with the app's react-native path, so it cannot be part of
 * an ownership test.
 *
 * `deinit` deletes a scheme the marker says RN created only while this holds. A
 * user may replace a generated scheme with one of their own under the same name
 * (same target, so `injectOrCreateScheme` finds and updates it, and the created
 * record stays), and destroying that is unrecoverable where leaving a scheme
 * behind is not — so the harmless way of being wrong wins: a generated scheme the
 * user has since edited leaks, minus its pre-action.
 */
function isGeneratedScheme(
  xml /*: string */,
  appName /*: string */,
  targetUuid /*: string */,
  projName /*: string */,
) /*: boolean */ {
  const script = schemePreActionScript(xml);
  return (
    script != null &&
    xml === generateXcscheme(appName, targetUuid, projName, script)
  );
}

/**
 * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added
 * to a scheme, and drop the `<PreActions>` wrapper if it is left empty (the
 * byte-identical inverse for the common case where injection created it).
 */
function removePreActionFromScheme(xml /*: string */) /*: string */ {
  const withoutAction = xml.replace(
    /[ \t]*<ExecutionAction\b(?:(?!<\/ExecutionAction>)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/,
    '',
  );
  return withoutAction.replace(/\n[ \t]*<PreActions>\s*<\/PreActions>/, '');
}

function removeRecordedBuildSettings(
  input /*: string */,
  changes /*: ReadonlyArray<BuildSettingChange> */,
) /*: string */ {
  let text = input;
  for (const change of changes) {
    const dict = () => {
      const config = findObjectByUuid(text, change.configUuid);
      if (config == null) {
        return null;
      }
      const buildSettings = findField(text, config, 'buildSettings');
      if (buildSettings == null) {
        return null;
      }
      return {
        uuid: change.configUuid,
        bodyOpen: buildSettings.valueStart,
        bodyClose: buildSettings.tokenEnd - 1,
      };
    };
    for (const key of Object.keys(change.appendedArrayValues ?? {})) {
      const current = dict();
      if (current != null) {
        text = removeArrayStringValues(
          text,
          current,
          key,
          change.appendedArrayValues[key],
        );
      }
    }
    const promotedArrayScalars /*: {[string]: string} */ =
      change.promotedArrayScalars ?? {};
    for (const key of Object.keys(promotedArrayScalars)) {
      const current = dict();
      const originalValue = promotedArrayScalars[key];
      // A field that is gone was deleted by the user after injection; restoring
      // it would resurrect it, at the top of the dict, matching neither state.
      if (
        current != null &&
        typeof originalValue === 'string' &&
        findField(text, current, key) != null
      ) {
        // Rewriting the whole value is what makes the promotion reversible at
        // all — its members and its `"$(inherited)"` seed are indistinguishable
        // from the user's own once folded together. The tradeoff: members the
        // user hand-added to the promoted array afterwards are discarded.
        text = setScalarField(text, current, key, originalValue);
      }
    }
    for (const key of change.createdArrayKeys ?? []) {
      const current = dict();
      if (current != null) {
        text = removeField(text, current, key);
      }
    }
    for (const key of change.createdScalars ?? []) {
      const current = dict();
      if (current != null) {
        text = removeField(text, current, key);
      }
    }
    const replacedScalars /*: {[string]: string} */ =
      change.replacedScalars ?? {};
    for (const key of Object.keys(replacedScalars)) {
      const current = dict();
      if (current != null) {
        text = removeField(text, current, key);
        const replacement = dict();
        if (replacement != null) {
          const originalValue = replacedScalars[key];
          if (typeof originalValue === 'string') {
            text = ensureScalarField(text, replacement, key, originalValue);
          }
        }
      }
    }
  }
  return text;
}

/**
 * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the
 * `.spm-injected.json` marker's precise record of every edit, remove only what
 * injection added — leaving any other (user) edits made afterwards intact. No
 * `git checkout`, no prompt. Returns {status:'absent'} when the project was
 * never injected.
 */
function removeSpmInjection(
  opts /*: {appRoot: string, xcodeprojPath: string} */,
) /*: {status: 'removed', target: string} | {status: 'absent'} */ {
  const {appRoot, xcodeprojPath} = opts;
  const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
  if (!fs.existsSync(markerPath)) {
    return {status: 'absent'};
  }
  // $FlowFixMe[incompatible-type] JSON.parse returns any
  const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
  let text = fs.readFileSync(pbxprojPath, 'utf8');

  const injectedUuids /*: Array<string> */ = marker.injectedUuids ?? [];

  // 1. Drop our array members, then the array fields we created (now empty),
  //    then the injected object definitions.
  text = removeArrayMembersByUuid(text, injectedUuids);
  for (const f of marker.createdArrayFields ?? []) {
    const obj =
      f.container === 'project'
        ? findProjectObject(text)
        : findObjectByUuid(text, marker.targetUuid);
    if (obj == null) {
      continue;
    }
    // The record says the field did not exist before RN created it, which is
    // necessary but not sufficient: anything still in it after our own members
    // are gone is the user's (their own SPM package, added to the same field),
    // and dropping the field would orphan it.
    const field = findField(text, obj, f.key);
    if (field != null && /^\(\s*\)$/.test(field.value)) {
      text = removeField(text, obj, f.key);
    }
  }
  for (const uuid of injectedUuids) {
    text = removeObjectByUuid(text, uuid);
  }
  // Drop any section that injection created and we just emptied (e.g.
  // XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an
  // empty `/* Begin X *​/ /* End X *​/` section, so this lands byte-identical.
  text = text.replace(
    /\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g,
    '',
  );

  // 2. Reverse the per-config build-setting edits (only what we added).
  text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []);
  writeIfChanged(pbxprojPath, text);
  log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`);

  // 3. Scheme: delete it if injection created it AND still owns its contents,
  //    else strip the pre-action and leave the file (see isGeneratedScheme).
  const scheme = marker.scheme;
  if (scheme != null && scheme.file != null) {
    const schemePath = path.join(
      xcodeprojPath,
      'xcshareddata',
      'xcschemes',
      scheme.file,
    );
    if (fs.existsSync(schemePath)) {
      const xml = fs.readFileSync(schemePath, 'utf8');
      const ours =
        scheme.created === true &&
        isGeneratedScheme(
          xml,
          marker.target,
          marker.targetUuid,
          path.basename(xcodeprojPath, '.xcodeproj'),
        );
      if (ours) {
        fs.rmSync(schemePath, {force: true});
      } else {
        writeIfChanged(schemePath, removePreActionFromScheme(xml));
      }
    }
  }

  // 4. Drop the marker — the project is no longer SPM-injected.
  fs.rmSync(markerPath, {force: true});
  return {status: 'removed', target: marker.target};
}

module.exports = {
  generateXcscheme,
  buildSyncAutolinkingScript,
  buildSchemePreActionScript,
  buildEmbedFrameworksScript,
  flavorForBuildConfiguration,
  targetBuildConfigUuids,
  frameworkConditionalSettings,
  ensureStubPackages,
  buildSpmDependencyGraph,
  spmGraphToEntries,
  buildPhaseOrder,
  planInjection,
  injectSpmIntoPbxproj,
  injectSpmIntoExistingXcodeproj,
  removeSpmInjection,
  cleanupLeftoverPodsGroup,
  cleanupDanglingJavaScriptCoreRef,
  addPreActionToScheme,
  removePreActionFromScheme,
  findInjectedXcodeproj,
  readMarker,
  readArtifactsVersionOverride,
  readPinnedConfigCommand,
  readScriptPhasesManifest,
  SPM_INJECTED_MARKER,
};