execute method
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);
}
}