run method
Runs this command.
The return value is wrapped in a Future if necessary and returned by
CommandRunner.runCommand.
Implementation
@override
Future<void> run() async {
final force = argResults!['force'] as bool;
final flavorsArg = argResults!['flavors'] as String?;
final projectTypeArg = argResults!['project-type'] as String?;
final cwd = Directory.current.path;
// Guard against spaces after commas splitting the --flavors value across args
// e.g. --flavors a, b → shell passes 'a,' and 'b' as separate tokens
if (argResults!.rest.isNotEmpty) {
AppLogger.error(
'Unexpected argument(s): ${argResults!.rest.join(' ')}\n'
' Did you add spaces after commas in --flavors?\n'
' Correct usage (no spaces): --flavors dev.myapp.stage,dev.myapp.qa,dev.myapp',
);
exit(ExitCodes.usage);
}
// 1. Determine project type
final projectType = projectTypeArg ?? _promptProjectType();
// 2. Validate project root + parse project info
late final String projectRoot;
if (projectType == 'flutter') {
final root = FlutterDetector.findProjectRoot(cwd);
if (root == null) {
AppLogger.error('Not a Flutter project — pubspec.yaml not found.');
exit(ExitCodes.usage);
}
projectRoot = root;
final pubspec = FlutterDetector.parsePubspec(projectRoot);
AppLogger.info('Project : ${pubspec['name']} (v${pubspec['version']})');
} else {
if (!File(p.join(cwd, 'package.json')).existsSync()) {
AppLogger.error('Not a React Native project — package.json not found.');
exit(ExitCodes.usage);
}
final gradlePath = GradleParser.resolvePath(cwd);
if (gradlePath == null) {
AppLogger.error(
'android/app/build.gradle(.kts) not found.\n'
' Run appflight init from your React Native project root.',
);
exit(ExitCodes.usage);
}
projectRoot = cwd;
final pkgJson = jsonDecode(
File(p.join(cwd, 'package.json')).readAsStringSync(),
) as Map<String, dynamic>;
final projectName = pkgJson['name'] as String? ?? '';
final gradleContent = File(gradlePath).readAsStringSync();
final gradleFileName = p.basename(gradlePath);
final versionName = GradleParser.parseVersionName(gradleContent);
final versionCode = GradleParser.parseVersionCode(gradleContent);
AppLogger.info('Project : $projectName');
AppLogger.info(
'Version : $versionName+$versionCode (from android/app/$gradleFileName)',
);
}
stdout.writeln('');
// 3. Guard against overwriting without --force
if (AppConfig.load(projectRoot) != null && !force) {
AppLogger.warn(
'appflight.json already exists. Use --force to overwrite.',
);
exit(ExitCodes.usage);
}
// 4. Tip for unflavored
if (flavorsArg == null) {
AppLogger.info('Tip: For apps with multiple flavors run:');
AppLogger.info(
' appflight init --flavors stage:dev.myapp.stage,prod:dev.myapp',
);
stdout.writeln('');
}
final apps = <String, AppEntry>{};
String? defaultFlavor;
if (flavorsArg != null) {
// --- Multi-flavor: package names passed directly via --flavors ---
final entries = flavorsArg
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (entries.isEmpty) {
AppLogger.error(
'--flavors is empty.\n'
' Example: --flavors stage:dev.myapp.stage,qa:dev.myapp.qa,prod:dev.myapp',
);
exit(ExitCodes.usage);
}
final usedPackageNames = <String>{};
final usedFlavorKeys = <String>{};
for (final entry in entries) {
final parts = entry.split(':');
if (parts.length != 2 ||
parts[0].trim().isEmpty ||
parts[1].trim().isEmpty) {
AppLogger.error(
'Invalid format: "$entry"\n'
' Each entry must be name:packageName.\n'
' Example: stage:dev.myapp.stage',
);
exit(ExitCodes.usage);
}
final flavorKey = parts[0].trim();
final packageName = parts[1].trim();
if (usedFlavorKeys.contains(flavorKey)) {
AppLogger.error('Duplicate flavor name: "$flavorKey"');
exit(ExitCodes.usage);
}
final error = validatePackageName(packageName, usedPackageNames);
if (error != null) {
AppLogger.error(error);
exit(ExitCodes.usage);
}
usedPackageNames.add(packageName);
usedFlavorKeys.add(flavorKey);
apps[flavorKey] = AppEntry(
appflightAppId: packageName,
packageName: packageName,
apkPath: projectType == 'react-native'
? p.join(
'android',
'app',
'build',
'outputs',
'apk',
flavorKey,
'release',
'app-$flavorKey-release.apk',
)
: FlutterDetector.defaultApkPath(flavorKey),
);
}
if (apps.length > 1) {
final keys = apps.keys.toList();
final def = _prompt('Default flavor [${keys.first}]: ');
defaultFlavor = def.isEmpty ? keys.first : def;
if (!apps.containsKey(defaultFlavor)) {
AppLogger.error('Unknown flavor: $defaultFlavor');
exit(ExitCodes.usage);
}
}
} else {
// --- No-flavor: single package name, prompted interactively ---
final packageName = _promptPackageName(
'Package name (applicationId): ',
{},
);
apps['default'] = AppEntry(
appflightAppId: packageName,
packageName: packageName,
apkPath: projectType == 'react-native'
? p.join(
'android',
'app',
'build',
'outputs',
'apk',
'release',
'app-release.apk',
)
: FlutterDetector.defaultSingleApkPath(),
);
}
// 5. Write config
AppConfig(
version: 1,
projectType: projectType,
apps: apps,
defaultFlavor: defaultFlavor,
).save(projectRoot);
stdout.writeln('');
AppLogger.success('appflight.json written.');
CliAnalytics.ins.logInitCompleted(projectType: projectType);
stdout.writeln('');
AppLogger.info('Next steps:');
AppLogger.info(' 1. appflight login');
final flavor = defaultFlavor ?? (apps.length == 1 ? null : apps.keys.first);
if (projectType == 'react-native') {
final gradleTask = flavor != null
? 'assemble${_capitalize(flavor)}Release'
: 'assembleRelease';
AppLogger.info(' 2. cd android && ./gradlew $gradleTask');
} else {
final buildArg = flavor != null ? ' --flavor $flavor' : '';
AppLogger.info(' 2. flutter build apk$buildArg --release');
}
AppLogger.info(
' 3. appflight upload${flavor != null ? ' --flavor $flavor' : ''}',
);
}