execute method

Future<void> execute(
  1. String projectPath
)

Execute verification

Implementation

Future<void> execute(String projectPath) async {
  final absolutePath = path.absolute(projectPath);
  final dir = Directory(absolutePath);

  if (!dir.existsSync()) {
    stderr.writeln(ConsoleStyle.error('Error: Project path does not exist: $absolutePath'));
    exit(1);
  }

  // Step 1: Detect project type
  final detectSpinner = ProgressSpinner('Detecting project type...', verbose: verbose);
  detectSpinner.start();

  final projectType = ProjectDetector.detectProjectType(absolutePath);

  if (projectType == ProjectType.unknown) {
    detectSpinner.fail('Could not detect project type');
    stderr.writeln(ConsoleStyle.error(
      'Please run this command from your project root directory',
    ));
    exit(1);
  }

  detectSpinner.success('Detected ${projectType.name} project');

  final results = <VerificationResult>[];

  // 1. Validate SDK package installation
  final sdkSpinner = ProgressSpinner('Validating SDK package installation...', verbose: verbose);
  sdkSpinner.start();
  final sdkResults = SdkPackageValidator.validate(absolutePath, projectType);
  results.addAll(sdkResults);
  final sdkHasErrors = sdkResults.any((r) => r.status == VerificationStatus.error);
  if (sdkHasErrors) {
    sdkSpinner.warn('SDK validation completed with issues');
  } else {
    sdkSpinner.success('SDK packages validated');
  }

  // 2. Parse local configuration
  final parseSpinner = ProgressSpinner('Parsing local configuration...', verbose: verbose);
  parseSpinner.start();
  PlatformConfig? localConfig;

  if (projectType == ProjectType.flutter) {
    localConfig = FlutterParser.parseFlutterProject(absolutePath);
  } else if (projectType == ProjectType.ios) {
    // For iOS projects, we need to fetch ULink config early to get the bundle ID
    // for auto target detection
    String? targetBundleId;

    // Check for credentials early
    final earlyDirProjectId = ProjectConfigManager.loadProjectId(absolutePath);
    final earlyConfig = ConfigManager.loadConfig();
    String? earlyApiKey;
    final earlyProjectId = earlyDirProjectId;

    if (earlyConfig?.auth?.type == AuthType.apiKey) {
      earlyApiKey = earlyConfig!.auth!.apiKey;
    }

    final hasEarlyCredentials = earlyApiKey != null ||
        (earlyConfig?.auth?.type == AuthType.jwt && earlyConfig!.auth!.token != null);

    // If we have credentials, try to get bundle ID from ULink
    if (hasEarlyCredentials && earlyProjectId != null) {
      try {
        final apiClient = ULinkApiClient(
          baseUrl: baseUrl,
          apiKey: earlyApiKey,
        );
        final ulinkProjectConfig = await apiClient.getProjectConfig(earlyProjectId);
        targetBundleId = ulinkProjectConfig.iosBundleIdentifier;
      } catch (e) {
        // Continue without ULink bundle ID - will use fallback
        if (verbose) {
          print(ConsoleStyle.dim('Could not fetch ULink config for target detection: $e'));
        }
      }
    }

    // Use target discovery
    final discoveryResult = ProjectDetector.discoverTargetByBundleId(
      absolutePath,
      projectType,
      targetBundleId,
    );

    if (discoveryResult.allTargets.isEmpty) {
      // No entitlements found at all
      parseSpinner.warn('No entitlements files found');
      results.add(
        VerificationResult(
          checkName: 'iOS Target Discovery',
          status: VerificationStatus.warning,
          message: 'No entitlements files found in project',
          fixSuggestion: 'Add an entitlements file with com.apple.developer.associated-domains',
        ),
      );
      // Still try to parse Info.plist for other config
      final infoPlist = ProjectDetector.findInfoPlist(absolutePath, projectType);
      if (infoPlist != null) {
        localConfig = IosParser.parseInfoPlist(infoPlist);
      }
    } else if (!discoveryResult.hasMatch && targetBundleId != null) {
      // Found targets but none match the ULink bundle ID
      parseSpinner.fail('No matching target found');
      final targetList = discoveryResult.allTargets
          .map((t) => '  • ${t.targetName} (${t.bundleId})\n    └─ ${t.entitlementsFile.path}')
          .join('\n');
      stderr.writeln(ConsoleStyle.error(
        '\nNo local target matches ULink bundle ID: $targetBundleId\n\n'
        'Found ${discoveryResult.allTargets.length} target(s) in project:\n$targetList\n\n'
        'Either update your ULink iOS Bundle Identifier, or ensure the correct\n'
        'target has an entitlements file with associated domains configured.',
      ));
      exit(1);
    } else {
      // Found a match (or using first target as fallback)
      final matchedTarget = discoveryResult.matchedTarget!;

      if (discoveryResult.hasMultipleTargets && targetBundleId == null && !hasEarlyCredentials) {
        // Multiple targets but no credentials to determine which one
        print(ConsoleStyle.warning(
          '\n⚠ Multiple targets found. Using first target: ${matchedTarget.targetName}\n'
          '  Run "ulink login" for automatic target matching by bundle ID\n',
        ));
      } else if (discoveryResult.hasMultipleTargets) {
        print(ConsoleStyle.success('✓ Matched target: ${matchedTarget.targetName} (${matchedTarget.bundleId})'));
      }

      // Parse the matched target
      localConfig = IosParser.parseInfoPlist(matchedTarget.infoPlistFile);
      if (localConfig != null) {
        final domains = IosParser.parseEntitlements(matchedTarget.entitlementsFile);
        localConfig = PlatformConfig(
          projectType: localConfig.projectType,
          bundleIdentifier: localConfig.bundleIdentifier,
          urlSchemes: localConfig.urlSchemes,
          iosUrlSchemes: localConfig.iosUrlSchemes,
          androidUrlSchemes: localConfig.androidUrlSchemes,
          associatedDomains: domains,
          teamId: localConfig.teamId,
        );
      }
    }
  } else if (projectType == ProjectType.android) {
    final androidManifest = ProjectDetector.findAndroidManifest(
      absolutePath,
      projectType,
    );
    if (androidManifest != null) {
      localConfig = AndroidParser.parseAndroidManifest(androidManifest);
    }
  } else if (projectType == ProjectType.reactNative) {
    // React Native is cross-platform: parse whatever native config exists
    // (bare RN or post-`expo prebuild`). Managed Expo projects yield an
    // empty-but-non-null config; native checks are gated on dir existence.
    localConfig = ReactNativeParser.parseReactNativeProject(absolutePath);
  }

  if (localConfig == null) {
    parseSpinner.fail('Failed to parse configuration');
    results.add(
      VerificationResult(
        checkName: 'Local Configuration Parsing',
        status: VerificationStatus.error,
        message: 'Failed to parse local configuration files',
        fixSuggestion: 'Ensure project files are properly configured',
      ),
    );
  } else {
    parseSpinner.success('Configuration parsed');
    results.add(
      VerificationResult(
        checkName: 'Local Configuration Parsing',
        status: VerificationStatus.success,
        message: 'Successfully parsed local configuration',
      ),
    );
  }

  // 3. Validate local configuration files
  final validateSpinner = ProgressSpinner('Validating configuration files...', verbose: verbose);
  validateSpinner.start();
  // React Native native dirs only exist for bare RN or after `expo prebuild`.
  final rnHasIos = projectType == ProjectType.reactNative &&
      Directory(path.join(absolutePath, 'ios')).existsSync();
  final rnHasAndroid = projectType == ProjectType.reactNative &&
      Directory(path.join(absolutePath, 'android')).existsSync();

  if (projectType == ProjectType.flutter ||
      projectType == ProjectType.ios ||
      rnHasIos) {
    results.addAll(IosValidator.validate(absolutePath, localConfig));
  }
  if (projectType == ProjectType.flutter ||
      projectType == ProjectType.android ||
      rnHasAndroid) {
    results.addAll(AndroidValidator.validate(absolutePath, localConfig));
  }
  if (projectType == ProjectType.reactNative && !rnHasIos && !rnHasAndroid) {
    results.add(
      VerificationResult(
        checkName: 'React Native Native Config',
        status: VerificationStatus.skipped,
        message:
            'No native ios/ or android/ directories found (managed Expo workflow)',
        fixSuggestion:
            'Native deep-link config is applied by the Expo config plugin during '
            '`npx expo prebuild`. Ensure the plugin is in app.json, or run prebuild '
            'to generate native files for full verification.',
      ),
    );
  }
  validateSpinner.success('Configuration files validated');

  // 4. Fetch ULink configuration
  ProjectConfig? ulinkConfig;

  // Try to get project ID and credentials from various sources
  // Priority: 1. Directory config, 2. Global config
  String? effectiveProjectId;
  String? effectiveApiKey;

  // Load from directory config (per-directory project selection)
  final dirProjectId = ProjectConfigManager.loadProjectId(absolutePath);
  if (dirProjectId != null) {
    effectiveProjectId = dirProjectId;
    if (verbose) {
      print(ConsoleStyle.dim('Using project from directory config: $dirProjectId'));
    }
  }

  final config = ConfigManager.loadConfig();

  // API key can come from stored config or project-specific config
  if (config?.auth?.type == AuthType.apiKey) {
    effectiveApiKey = config!.auth!.apiKey;
  } else if (effectiveProjectId != null &&
      config?.projects.isNotEmpty == true) {
    // Try to find project-specific API key
    final project = config!.projects.firstWhere(
      (p) => p.projectId == effectiveProjectId,
      orElse: () => config.projects.first,
    );
    effectiveApiKey = project.apiKey;
  }

  // Check if we have credentials (either provided or from config)
  final hasCredentials = effectiveApiKey != null ||
      (config?.auth?.type == AuthType.jwt && config!.auth!.token != null);

  // If we have credentials but no project ID, try to fetch and select a project
  if (effectiveProjectId == null && hasCredentials) {
    final fetchProjectsSpinner = ProgressSpinner('Fetching your projects...', verbose: verbose);
    fetchProjectsSpinner.start();

    try {
      final apiClient = ULinkApiClient(
        baseUrl: baseUrl,
        apiKey: effectiveApiKey,
      );
      final projects = await apiClient.getProjects();

      if (projects.isEmpty) {
        fetchProjectsSpinner.warn('No projects found');
        results.add(
          VerificationResult(
            checkName: 'ULink API Connection',
            status: VerificationStatus.warning,
            message:
                'No projects found. Create a project in the ULink dashboard first.',
            fixSuggestion: 'Visit https://ulink.ly to create a project',
          ),
        );
      } else if (projects.length == 1) {
        // Auto-select if only one project
        effectiveProjectId = projects.first.id;
        fetchProjectsSpinner.success('Auto-selected project: ${projects.first.name}');

        try {
          // Save to directory config (per-directory project selection)
          await ProjectConfigManager.saveProjectId(
            absolutePath,
            projects.first.id,
            projectName: projects.first.name,
          );
          if (verbose) {
            print(ConsoleStyle.success(
                '✓ Saved project to directory config: ${path.join(absolutePath, '.ulink', 'project.json')}'));
          }
        } catch (e) {
          stderr.writeln(ConsoleStyle.warning(
              'Warning: Could not save project to directory config: $e'));
        }
      } else {
        // Stop spinner before showing selection menu
        fetchProjectsSpinner.success('Found ${projects.length} projects');

        // Show selection menu for multiple projects (always show - user input needed)
        print('\n${ConsoleStyle.info('Select a project:')}');
        for (int i = 0; i < projects.length; i++) {
          print('  ${i + 1}. ${projects[i].name} ${ConsoleStyle.dim('(${projects[i].id})')}');
        }
        stdout.write('\nEnter project number (1-${projects.length}): ');
        final input = stdin.readLineSync()?.trim();
        final selectedIndex = int.tryParse(input ?? '') ?? 0;

        if (selectedIndex < 1 || selectedIndex > projects.length) {
          results.add(
            VerificationResult(
              checkName: 'ULink API Connection',
              status: VerificationStatus.skipped,
              message: 'Invalid project selection',
              fixSuggestion:
                  'Run the command again and select a valid project',
            ),
          );
        } else {
          final selectedProject = projects[selectedIndex - 1];
          effectiveProjectId = selectedProject.id;
          print(ConsoleStyle.success('✓ Selected project: ${selectedProject.name}'));

          try {
            // Save to directory config (per-directory project selection)
            await ProjectConfigManager.saveProjectId(
              absolutePath,
              selectedProject.id,
              projectName: selectedProject.name,
            );
            if (verbose) {
              print(ConsoleStyle.success(
                  '✓ Saved project to directory config: ${path.join(absolutePath, '.ulink', 'project.json')}'));
            }
          } catch (e) {
            stderr.writeln(ConsoleStyle.warning(
                'Warning: Could not save project to directory config: $e'));
          }
        }
      }
    } catch (e) {
      fetchProjectsSpinner.fail('Failed to fetch projects');
      results.add(
        VerificationResult(
          checkName: 'ULink API Connection',
          status: VerificationStatus.error,
          message: 'Failed to fetch projects: $e',
          fixSuggestion:
              'Check your credentials. Run "ulink login" to authenticate.',
        ),
      );
    }
  }

  // Now fetch project configuration if we have a project ID
  if (effectiveProjectId != null && hasCredentials) {
    final fetchConfigSpinner = ProgressSpinner('Fetching ULink configuration...', verbose: verbose);
    fetchConfigSpinner.start();

    try {
      final apiClient = ULinkApiClient(
        baseUrl: baseUrl,
        apiKey: effectiveApiKey,
      );
      ulinkConfig = await apiClient.getProjectConfig(effectiveProjectId);
      fetchConfigSpinner.success('ULink configuration fetched');
      results.add(
        VerificationResult(
          checkName: 'ULink API Connection',
          status: VerificationStatus.success,
          message: 'Successfully fetched project configuration',
        ),
      );
    } catch (e) {
      fetchConfigSpinner.fail('Failed to fetch configuration');
      results.add(
        VerificationResult(
          checkName: 'ULink API Connection',
          status: VerificationStatus.error,
          message: 'Failed to fetch project configuration: $e',
          fixSuggestion:
              'Check your credentials. Run "ulink login" to authenticate.',
        ),
      );
    }
  } else if (effectiveProjectId == null) {
    results.add(
      VerificationResult(
        checkName: 'ULink API Connection',
        status: VerificationStatus.skipped,
        message: 'Project ID and credentials not provided',
        fixSuggestion:
            'Run "ulink login" to authenticate, or provide --project-id and --api-key',
      ),
    );
  }

  // 5. Cross-reference configurations
  if (ulinkConfig != null && localConfig != null) {
    final crossRefSpinner = ProgressSpinner('Cross-referencing configurations...', verbose: verbose);
    crossRefSpinner.start();

    if (projectType == ProjectType.flutter ||
        projectType == ProjectType.ios ||
        rnHasIos) {
      results.addAll(ConfigValidator.validateIos(localConfig, ulinkConfig));
    }
    if (projectType == ProjectType.flutter ||
        projectType == ProjectType.android ||
        rnHasAndroid) {
      results.addAll(
        ConfigValidator.validateAndroid(localConfig, ulinkConfig),
      );
    }
    crossRefSpinner.success('Cross-referenced configurations');
  }

  // 6. Test well-known files
  if (ulinkConfig != null && ulinkConfig.domains.isNotEmpty) {
    final wellKnownSpinner = ProgressSpinner('Testing well-known files...', verbose: verbose);
    wellKnownSpinner.start();

    final verifiedDomains =
        ulinkConfig.domains.where((d) => d.status == 'verified').toList();
    final verifiedDomain =
        verifiedDomains.isNotEmpty ? verifiedDomains.first : null;

    if (verifiedDomain != null) {
      // Test AASA file for iOS
      if (projectType == ProjectType.flutter ||
          projectType == ProjectType.ios ||
          projectType == ProjectType.reactNative) {
        if (ulinkConfig.iosTeamId != null &&
            ulinkConfig.iosBundleIdentifier != null) {
          final aasaResult = await WellKnownTester.testAasaFile(
            verifiedDomain.host,
            ulinkConfig.iosTeamId,
            ulinkConfig.iosBundleIdentifier,
          );
          results.add(aasaResult);
        }
      }

      // Test Asset Links file for Android
      if (projectType == ProjectType.flutter ||
          projectType == ProjectType.android ||
          projectType == ProjectType.reactNative) {
        if (ulinkConfig.androidPackageName != null) {
          final assetLinksResult = await WellKnownTester.testAssetLinksFile(
            verifiedDomain.host,
            ulinkConfig.androidPackageName,
            ulinkConfig.androidSha256Fingerprints,
          );
          results.add(assetLinksResult);
        }
      }
      wellKnownSpinner.success('Well-known files tested');
    } else {
      wellKnownSpinner.warn('No verified domains to test');
    }
  }

  // 7. Runtime tests (optional, can be skipped if devices not available)
  if (projectType == ProjectType.flutter ||
      projectType == ProjectType.ios ||
      projectType == ProjectType.reactNative) {
    if (ulinkConfig != null && ulinkConfig.domains.isNotEmpty) {
      final iosRuntimeSpinner = ProgressSpinner('Running iOS runtime tests...', verbose: verbose);
      iosRuntimeSpinner.start();

      final verifiedDomains =
          ulinkConfig.domains.where((d) => d.status == 'verified').toList();
      final domain = verifiedDomains.isNotEmpty
          ? verifiedDomains.first
          : ulinkConfig.domains.first;

      // Check domain association status instead of just opening URL
      final runtimeResult =
          await IosRuntimeTester.checkDomainAssociationStatus(
        domain.host,
        localConfig?.bundleIdentifier,
      );
      results.add(runtimeResult);

      if (runtimeResult.status == VerificationStatus.success) {
        iosRuntimeSpinner.success('iOS runtime tests passed');
      } else if (runtimeResult.status == VerificationStatus.warning) {
        iosRuntimeSpinner.warn('iOS runtime tests completed with warnings');
      } else if (runtimeResult.status == VerificationStatus.skipped) {
        iosRuntimeSpinner.success('iOS runtime tests skipped');
      } else {
        iosRuntimeSpinner.fail('iOS runtime tests failed');
      }
    }
  }

  if (projectType == ProjectType.flutter ||
      projectType == ProjectType.android ||
      projectType == ProjectType.reactNative) {
    if (ulinkConfig != null && localConfig?.packageName != null) {
      final androidRuntimeSpinner = ProgressSpinner('Running Android runtime tests...', verbose: verbose);
      androidRuntimeSpinner.start();

      final statusResult = await AndroidRuntimeTester.getAppLinksStatus(
        localConfig!.packageName!,
      );
      results.add(statusResult);

      if (statusResult.status == VerificationStatus.success) {
        androidRuntimeSpinner.success('Android runtime tests passed');
      } else if (statusResult.status == VerificationStatus.warning) {
        androidRuntimeSpinner.warn('Android runtime tests completed with warnings');
      } else if (statusResult.status == VerificationStatus.skipped) {
        androidRuntimeSpinner.success('Android runtime tests skipped');
      } else {
        androidRuntimeSpinner.fail('Android runtime tests failed');
      }
    }
  }

  // Generate and display report
  final report = VerificationReport(
    projectType: projectType,
    results: results,
  );

  print(ReportGenerator.generateReport(report, verbose: verbose));

  // Auto-upload results to ULink dashboard if authenticated
  if (effectiveProjectId != null && hasCredentials) {
    final uploadSpinner = ProgressSpinner('Syncing results to dashboard...', verbose: verbose);
    uploadSpinner.start();

    try {
      final apiClient = ULinkApiClient(
        baseUrl: baseUrl,
        apiKey: effectiveApiKey,
      );

      // Generate JSON report with passed status
      final jsonReport = ReportGenerator.generateJsonReport(report);
      jsonReport['passed'] = !report.hasErrors;

      await apiClient.postVerificationResults(effectiveProjectId, jsonReport);
      uploadSpinner.success('Results synced to dashboard');
    } catch (e) {
      // Don't fail the verification if upload fails
      uploadSpinner.warn('Could not sync results to dashboard');
      if (verbose) {
        stderr.writeln(ConsoleStyle.warning('The verification completed locally, but results were not synced to the dashboard.'));
        stderr.writeln(ConsoleStyle.dim('Error: $e'));
      }
    }
  }

  // Exit with appropriate code
  if (report.hasErrors) {
    exit(1);
  } else {
    exit(0);
  }
}