run method

  1. @override
Future<void> run()
override

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 {
    final (info, error) = GradleProjectDetector.resolve(projectType, cwd);
    if (info == null) {
      AppLogger.error(error!);
      exit(ExitCodes.usage);
    }
    projectRoot = cwd;
    AppLogger.info('Project : ${info.projectName}');
    AppLogger.info(
      'Version : ${info.versionName}+${info.versionCode}  (from ${info.gradleFileLabel})',
    );
    if (projectType == 'kotlin') {
      AppLogger.info(
        'Tip: a fresh Android Studio project has no release signingConfig '
        'by default — `assembleRelease` will output app-release-unsigned.apk '
        'unless you add one (a debug keystore is fine for local testing).',
      );
    }
  }
  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',
              )
            : projectType == 'kotlin'
                ? p.join(
                    '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',
            )
          : projectType == 'kotlin'
              ? p.join(
                  '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 if (projectType == 'kotlin') {
    final gradleTask = flavor != null
        ? 'assemble${_capitalize(flavor)}Release'
        : 'assembleRelease';
    AppLogger.info('  2. ./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' : ''}',
  );
}