run method
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;
}