run method

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

Runs the command to create a job and update the config file.

Returns 0 on success and 1 when the job could not be created.

Implementation

@override
Future<int> run() async {
  final configPath = super.configPath;

  final file = File(configPath);
  if (!file.existsSync()) {
    logger.logError("Configuration file not found: $configPath");
    return 1;
  }

  final Map<String, dynamic> configJson;
  try {
    configJson = _loadYamlAsJson(file);
  } on ConfigException catch (e) {
    logger.logError(e.message);
    return 1;
  }
  // `variables:` is optional, so default to an empty map instead of handing
  // a null to Variables and crashing on the first lookup.
  final Variables variables = Variables(
    Map<String, dynamic>.from((configJson["variables"] as Map?) ?? const {}),
    globalResults,
  );
  final tasks = (configJson["tasks"] as List?) ?? [];

  String? taskKey;
  String? jobKey;
  String? jobName;
  String? packageName;
  String? description;
  String? appId;

  bool isWizard = argResults?["wizard"] ?? false;

  final kind = this is CreateBuilderCommand ? 'builder' : 'publisher';
  Prompt? wizard;
  int? pickedTaskIndex;

  if (isWizard) {
    if (tasks.isEmpty) {
      logger.logError("$configPath has no task to add a job to");
      logger.logDetail("run `distribute create task -w` first");
      return 1;
    }

    wizard = openWizard('create $kind job', configPath);

    // Picked from a numbered list rather than typed: a task key that does not
    // exist used to be discovered only after the question was answered, and
    // rejecting it meant restarting the wizard.
    final choices = [
      for (var i = 0; i < tasks.length; i++)
        (index: i, task: Map<String, dynamic>.from(tasks[i] as Map)),
    ];
    final chosen = wizard.select(
      'Which task does this job belong to?',
      choices,
      label: (c) => '${c.task["name"]}  ${c.task["key"]}',
      describe: (c) {
        final existing = (c.task["jobs"] as List?) ?? const [];
        if (existing.isEmpty) return 'no jobs yet';
        return existing.map((job) => "${job["key"]}").join(', ');
      },
    );
    // The position is what identifies the task, not the key: a configuration
    // with two tasks sharing a key would otherwise write the job into the
    // first one, whichever the user pointed at.
    pickedTaskIndex = chosen.index;
    final task = chosen.task;
    taskKey = "${task["key"]}";

    final taken = ((task["jobs"] as List?) ?? const [])
        .map((job) => "${job["key"]}")
        .toSet();

    logger.logEmpty();
    jobName = wizard.text('Job name', defaultValue: _suggestedJobName(kind));
    jobKey = wizard.text(
      'Job key',
      defaultValue: CreatorCommand.slugify(jobName),
      validate: (value) =>
          CreatorCommand.validateKey(value, taken: taken, what: 'job'),
    );
    description = wizard.text('Description', allowEmpty: true);

    // The detected package name is offered as a default, not forced: a
    // project can publish under a different id than the one in the Gradle
    // file, and the old wizard never gave the user the chance to say so.
    packageName = wizard.text(
      'Package name',
      defaultValue: BuildInfo.androidPackageName ??
          BuildInfo.iosBundleId ??
          "\${ANDROID_PACKAGE}",
    );
  } else {
    taskKey = argResults?["task-key"];
    jobKey = argResults?["key"];
    jobName = argResults?["name"];
    packageName = argResults?["package-name"];
    description = argResults?["description"];
  }

  taskKey = await variables.process(taskKey ?? "");
  jobKey = await variables.process(jobKey ?? "");
  jobName = await variables.process(jobName ?? "");
  description = await variables.process(description ?? "");
  packageName = await variables.process(packageName ?? "\${ANDROID_PACKAGE}");

  final googleServiceFile = File(
    path.join("android", "app", "google-services.json"),
  );
  if (googleServiceFile.existsSync()) {
    final googleService = jsonDecode(googleServiceFile.readAsStringSync());
    final List clients = googleService["client"];
    final client = clients.firstWhere(
      (client) =>
          client["client_info"]["android_client_info"]["package_name"] ==
          packageName,
      orElse: () => {},
    );
    if (client.isNotEmpty) {
      appId = client["client_info"]["mobilesdk_app_id"];
    } else {
      logger.logWarning(
        "No Android client found in google-services.json. Please provide package name manually.",
      );
    }
  }

  // The wizard validates as it asks; the option form has to be checked here,
  // or `-k my.key` writes a job that `run -o task.my.key` can never address.
  final keyProblem = CreatorCommand.validateKey(
    jobKey,
    taken: const {},
    what: 'job',
  );
  if (jobKey.isNotEmpty && keyProblem != null) {
    logger.logError(keyProblem);
    return 1;
  }

  if ((taskKey.isEmpty) ||
      (jobKey.isEmpty) ||
      (jobName.isEmpty) ||
      (packageName.isEmpty)) {
    logger.logError(
      "`task-key`, `key`, `package_name`, and `name` are mandatory.",
    );
    return 1;
  }

  final taskIndex =
      pickedTaskIndex ?? tasks.indexWhere((task) => task["key"] == taskKey);
  if (taskIndex == -1) {
    logger.logError("Task with key $taskKey not found.");
    return 1;
  }

  var task = tasks[taskIndex];
  var jobs = task["jobs"] ?? [];

  if (jobs.any((job) => job["key"] == jobKey)) {
    logger.logError("Job with key $jobKey already exists.");
    return 1;
  }

  BuilderJob? builderJob;

  if (this is CreateBuilderCommand) {
    List<String> platforms = <String>[];
    if (isWizard) {
      // iOS builds need Xcode, so it is only offered where it can run.
      final available = [
        'android',
        if (Platform.isMacOS) 'ios',
      ];
      logger.logEmpty();
      platforms = available.length == 1
          ? available
          : wizard!.multiSelect(
              'Which platforms should this job build?',
              available,
              label: (platform) => platform,
              describe: (platform) => platform == 'android'
                  ? 'APK or AAB via Gradle'
                  : 'IPA via Xcode',
              defaults: const ['android'],
            );
    } else if (argResults!["platform"] != null) {
      platforms = (argResults!["platform"] as List<String>).toList();
    }

    final supported = platforms
        .where((platform) => platform == 'android' || platform == 'ios')
        .toList();
    if (supported.isEmpty) {
      // Constructing BuilderJob with nothing set throws a message that never
      // mentions the option the user actually has to pass.
      logger.logError("a builder job needs at least one platform");
      logger.logDetail(
        Platform.isMacOS
            ? "pass -P android and/or -P ios, or use -w for the wizard"
            : "pass -P android, or use -w for the wizard",
      );
      return 1;
    }
    platforms = supported;

    builderJob = BuilderJob(
      android: platforms.contains("android") == true
          ? android_arguments.Arguments.defaultConfigs(globalResults)
          : null,
      ios: platforms.contains("ios") == true
          ? ios_arguments.Arguments.defaultConfigs(globalResults)
          : null,
    );
  } else {
    builderJob = null;
  }

  PublisherJob? publisherJob;

  if (this is CreatePublisherCommand) {
    List<String> tools = <String>[];

    if (isWizard) {
      // This used to read stdin directly, which meant it neither validated
      // the answer nor stopped at end-of-input like every other prompt.
      const descriptions = {
        'firebase': 'Firebase App Distribution',
        'fastlane': 'Play Store, via Fastlane supply',
        'xcrun': 'App Store Connect, via altool',
        'github': 'GitHub Releases',
        'huawei': 'Huawei AppGallery',
      };
      final available = [
        'firebase',
        'fastlane',
        if (Platform.isMacOS) 'xcrun',
        'github',
        'huawei',
      ];
      logger.logEmpty();
      tools = wizard!.multiSelect(
        'Where should this job publish to?',
        available,
        label: (tool) => tool,
        describe: (tool) => descriptions[tool]!,
      );
    } else if (argResults!["tools"] != null) {
      tools = (argResults!["tools"] as List<String>).toList();
    }

    const known = {'fastlane', 'firebase', 'xcrun', 'github', 'huawei'};
    final supported = tools
        .where((tool) => known.contains(tool))
        .where((tool) => tool != 'xcrun' || Platform.isMacOS)
        .toList();
    if (supported.isEmpty) {
      logger.logError("a publisher job needs at least one tool");
      logger.logDetail(
        "pass -T with one of: "
        "${known.where((t) => t != 'xcrun' || Platform.isMacOS).join(', ')}"
        ", or use -w for the wizard",
      );
      return 1;
    }
    tools = supported;

    publisherJob = PublisherJob(
      fastlane: tools.contains("fastlane") == true
          ? fastlane_publisher.Arguments.defaultConfigs(
              packageName,
              globalResults,
            )
          : null,
      firebase: tools.contains("firebase") == true
          ? firebase_publisher.Arguments.defaultConfigs(
              appId ?? "APP_ID",
              globalResults,
            )
          : null,
      xcrun: Platform.isMacOS
          ? tools.contains("xcrun") == true
              ? xcrun_publisher.Arguments.defaultConfigs(globalResults)
              : null
          : null,
      github: tools.contains("github") == true
          ? github_publisher.Arguments.defaultConfigs(globalResults)
          : null,
      huawei: tools.contains("huawei") == true
          ? huawei_publisher.Arguments.defaultConfigs(globalResults)
          : null,
    );
  } else {
    publisherJob = null;
  }

  if (builderJob == null && publisherJob == null) {
    logger.logError("Invalid job type. Use 'builder' or 'publisher'.");
    return 1;
  }

  if (wizard != null) {
    logger.logEmpty();
    wizard.summary({
      'task': taskKey,
      'name': jobName,
      'key': jobKey,
      'description': description,
      'package': packageName,
      kind == 'builder' ? 'platforms' : 'publishes to':
          _selectedTargets(builderJob, publisherJob).join(', '),
      'reference': '$taskKey.$jobKey',
    });
    warnIfComments(file);
    logger.logEmpty();

    if (!wizard.confirm('Add this job to $configPath?')) {
      logger.logNote('nothing was written');
      return 0;
    }
  }

  jobs.add(
    Job(
      name: jobName,
      key: jobKey,
      description: description.isEmpty ? null : description,
      packageName: packageName,
      builder: builderJob,
      publisher: publisherJob,
    ).toJson(),
  );

  tasks[taskIndex]["jobs"] = jobs;

  configJson["tasks"] = tasks;
  await _writeYaml(file, configJson, warn: wizard == null);

  logger.logSuccess("added $taskKey.$jobKey to $configPath");
  if (wizard != null) {
    logger.logDetail('run it with `distribute run -o $taskKey.$jobKey`');
  }
  return 0;
}