runFirebase method

Future<ImportRunResult> runFirebase(
  1. ImportOptions opts
)

Run ulink import firebase. Returns the exit code + manifest; does not call exit() itself so it stays unit-testable.

Implementation

Future<ImportRunResult> runFirebase(ImportOptions opts) async {
  if (opts.help) {
    _Log.out(_styledUsage());
    return ImportRunResult(0, {});
  }
  if (opts.input == null) {
    _Log.err('Missing --input. See `ulink import firebase --help`.');
    return ImportRunResult(2, {});
  }
  if (opts.domain == null) {
    _Log.err(
        'Missing --domain (your ULink domain). See `ulink import firebase --help`.');
    return ImportRunResult(2, {});
  }

  final live = opts.live;
  final verify = opts.verify;
  final outDir = opts.out ?? 'ulink-import';
  final apiKey = opts.apiKey ?? Platform.environment['ULINK_API_KEY'];

  // 1. Read + parse the export.
  String raw;
  try {
    raw = await _readInput(opts.input!);
  } catch (e) {
    _Log.err('Could not read input "${opts.input}": $e');
    return ImportRunResult(1, {});
  }

  List<Map<String, dynamic>> links;
  try {
    links = FdlParser.parseExport(raw);
  } on FdlParseError catch (e) {
    _Log.err('Could not parse FDL export: ${e.message}');
    return ImportRunResult(1, {});
  }
  _Log.step(
      'Parsed ${ConsoleStyle.bold('${links.length}')} Firebase Dynamic Link${links.length == 1 ? '' : 's'} from ${opts.input}');

  if (live && (apiKey == null || apiKey.isEmpty)) {
    _Log.err('--live requires --api-key or the ULINK_API_KEY env var.');
    return ImportRunResult(2, {});
  }
  if (!live) {
    _Log.info(ConsoleStyle.dim(
        '  (dry-run: no links will be created; pass --live to write to ULink)'));
  }

  // 2. Map -> 3. create/preview -> 4. verify, per link.
  final results = <Map<String, dynamic>>[];
  var created = 0;
  var parityFailures = 0;
  final slugSeen = <String, int>{};

  for (var i = 0; i < links.length; i++) {
    final fdl = links[i];
    Map<String, dynamic> ulink;
    try {
      ulink = FdlMapper.mapToUlink(fdl, domain: opts.domain!, index: i);
    } catch (e) {
      _Log.err('Link #${i + 1}: mapping failed: $e');
      results.add({'index': i, 'ok': false, 'error': '$e', 'source': fdl});
      parityFailures++;
      continue;
    }

    // Guard against duplicate slugs colliding silently.
    final slug = ulink['slug'] as String;
    final prev = slugSeen[slug];
    if (prev != null) {
      ulink['slug'] = '$slug-${i + 1}';
      _Log.warn(
          'Link #${i + 1}: slug collided with #${prev + 1}; using "${ulink['slug']}" instead.');
    }
    slugSeen[ulink['slug'] as String] = i;

    CreateOutcome outcome;
    try {
      outcome = live
          ? await client.liveCreate(ulink, apiKey: apiKey!)
          : SdkLinksClient.previewCreate(ulink);
      if (outcome.status == 'created') created++;
    } catch (e) {
      _Log.err('Link #${i + 1}: create failed: $e');
      results.add({'index': i, 'ok': false, 'error': '$e', 'link': ulink});
      parityFailures++;
      continue;
    }

    final short = outcome.shortLink;

    // Static parity: the mapping preserves routing + attribution intent.
    ParityResult? parity;
    if (verify) parity = ImportParity.verifyParity(fdl, ulink);

    // Live redirect parity: ask the deployed edge where the created link
    // actually routes. Only meaningful for links created live; degrades to a
    // warning (not a failure) when /sdk/resolve is unreachable.
    LiveParityResult? liveParity;
    var liveProbed = false;
    if (verify && live && outcome.status == 'created') {
      liveProbed = true;
      final resolved = await client.liveResolve(short, apiKey: apiKey);
      if (resolved != null) {
        liveParity = ImportParity.verifyLiveRouting(outcome.payload, resolved);
      }
    }

    final staticOk = !verify || parity!.ok;
    final liveOk = liveParity == null || liveParity.ok;
    final linkOk = staticOk && liveOk;
    if (verify && !linkOk) parityFailures++;

    final tag = !verify
        ? ''
        : linkOk
            ? ' ${ConsoleStyle.success('parity OK')}'
            : ' ${ConsoleStyle.error('PARITY FAIL')}';
    final fromTo = fdl['shortLink'] != null
        ? '${ConsoleStyle.dim('${fdl['shortLink']}')} → '
        : '';
    _Log.ok('#${i + 1} $fromTo${ConsoleStyle.bold(short)}$tag');
    if (verify && parity != null && !parity.ok) {
      for (final ch in parity.failures) {
        _Log.warn(
            '    ${ch.field}: FDL=${jsonEncode(ch.fdl)} ULink=${jsonEncode(ch.ulink)}');
      }
    }
    if (liveParity != null && !liveParity.ok) {
      for (final ch in liveParity.failures) {
        _Log.warn(
            '    live ${ch.field}: sent=${jsonEncode(ch.sent)} resolved=${jsonEncode(ch.resolved)}');
      }
    }
    if (liveProbed && liveParity == null) {
      _Log.warn(
          '    live redirect parity skipped: GET /sdk/resolve unreachable for $short');
    }

    results.add({
      'index': i,
      'ok': linkOk,
      'shortLink': short,
      'status': outcome.status,
      'link': ulink,
      'parityOk': parity?.ok,
      'liveParityOk': liveParity?.ok,
    });
  }

  // 5. Write artifacts.
  final manifest = <String, dynamic>{
    'tool': 'ulink import firebase',
    'generatedFrom': opts.input,
    'domain': opts.domain,
    'mode': live ? 'live' : 'dry-run',
    'total': links.length,
    'created': created,
    'parityChecked': verify,
    'parityFailures': parityFailures,
    'links': results.map((r) {
      final link = r['link'] as Map<String, dynamic>?;
      return {
        'index': r['index'],
        'ok': r['ok'],
        'shortLink': r['shortLink'],
        'status': r['status'],
        'error': r['error'],
        'slug': link?['slug'],
        'destination': link?['destination'],
        'parityOk': r['parityOk'],
        'liveParityOk': r['liveParityOk'],
      };
    }).toList(),
  };

  try {
    await Directory(outDir).create(recursive: true);
    await File(p.join(outDir, 'manifest.json'))
        .writeAsString(_pretty(manifest));
    await Directory(p.join(outDir, 'links')).create(recursive: true);
    var linkCount = 0;
    for (final r in results) {
      final link = r['link'] as Map<String, dynamic>?;
      if (link == null) continue;
      await File(p.join(outDir, 'links', '${link['slug']}.json'))
          .writeAsString(_pretty(link));
      linkCount++;
    }
    _Log.step(
        'Wrote manifest + $linkCount link definitions to ${ConsoleStyle.bold(outDir)}/');
  } catch (e) {
    _Log.warn('Could not write artifacts to $outDir: $e');
  }

  if (opts.json) _Log.out(_pretty(manifest));

  // 6. Summary + exit code.
  final okCount = results.where((r) => r['ok'] == true).length;
  _Log.info('');
  if (parityFailures == 0) {
    _Log.ok(
        '$okCount/${links.length} links ${live ? 'created' : 'previewed'} with ${verify ? 'verified routing + attribution parity' : 'parity check skipped'}.');
    if (!live) {
      _Log.info(ConsoleStyle.dim(
          '  Re-run with --live --api-key <key> to create them in ULink.'));
    }
    return ImportRunResult(0, manifest);
  }
  _Log.err(
      '$parityFailures link(s) failed (mapping, create, or parity). See output above and $outDir/manifest.json.');
  return ImportRunResult(1, manifest);
}