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 apply = argResults!['apply'] as bool;
final output = p.posix.normalize(argResults!['output'] as String);
final className = argResults!['class-name'] as String;
final current = AppConfigSettings.load(projectDir);
if (current.isGenerated) {
Logger.success(
'This project already uses generated app config (${current.output}).');
return;
}
// Validate the requested settings with the same rules udara.yaml uses.
if (!output.startsWith('lib/') || !output.endsWith('.dart')) {
throw UsageException('--output must be a .dart file under lib/.', usage);
}
if (!RegExp(r'^[A-Z][A-Za-z0-9_]*$').hasMatch(className)) {
throw UsageException(
'--class-name must be a Dart class name like UdaraConfig.', usage);
}
checkProjectPrerequisites();
final clients = await listClientNames();
if (clients.isEmpty) {
throw BuildException('No clients found.',
fix: 'Run "udara_cli setup --clients <name>" first.');
}
final seedClient = clients.contains(WhiteLabelService.defaultClientName)
? WhiteLabelService.defaultClientName
: clients.first;
final seedEnv = File(p.join(clientsDir.path, seedClient, '.env'));
if (!seedEnv.existsSync()) {
throw BuildException('clients/$seedClient/.env is missing.',
fix: 'The generated class starts from it; create it first.');
}
final pubspec = File(p.join(projectDir, 'pubspec.yaml'));
final pubspecYaml = loadYaml(pubspec.readAsStringSync()) as YamlMap;
final packageName = '${pubspecYaml['name']}';
final importUri = 'package:$packageName/${output.substring('lib/'.length)}';
// ---- Plan -------------------------------------------------------------
final fileResults = <String, MigrationResult>{};
for (final file in _dartFiles(output)) {
final rel = p.relative(file.path, from: projectDir);
final source = file.readAsStringSync();
if (_isGenerated(rel)) {
if (source.contains('dotenv')) {
fileResults[rel] = MigrationResult(source, const [], [
const MigrationNote(1,
'Generated file mentions dotenv; regenerate it after migrating.')
]);
}
continue;
}
final r = DotenvMigration.migrateSource(source,
className: className, importUri: importUri);
if (r.changed || r.notes.isNotEmpty) fileResults[rel] = r;
}
final assets =
((pubspecYaml['flutter'] as YamlMap?)?['assets'] as YamlList?)
?.map((a) => '$a')
.toList() ??
<String>[];
final envAssets = assets.where(_isEnvAsset).toList();
// The keys the app's code reads become the include allow-list, so only
// they are compiled in. A computed key (dotenv.env[variable]) makes a
// complete list impossible; then every non-excluded key is included.
final usedKeys = <String>{};
final dynamicReads = <String>[];
final dynamicLiterals = <String>{};
for (final f in _dartFiles(output)) {
final rel = p.relative(f.path, from: projectDir).replaceAll('\\', '/');
if (!rel.startsWith('lib/') || _isGenerated(rel)) continue;
final scan = DotenvMigration.scanKeys(f.readAsStringSync());
usedKeys.addAll(scan.keys);
if (scan.dynamic) {
dynamicReads.add(rel);
dynamicLiterals.addAll(scan.literals);
}
}
final allEnvKeys = <String>{};
for (final client in clients) {
for (final name in const ['.env', '.env_test']) {
final f = File(p.join(clientsDir.path, client, name));
if (f.existsSync())
allEnvKeys
.addAll(ConfigService.parseEnvContent(f.readAsStringSync()).keys);
}
}
// Computed keys are usually spelled as literals nearby; those that name
// real .env keys are the best guess at what the code reads.
final detected =
({...usedKeys, ...dynamicLiterals.where(allEnvKeys.contains)}.toList()
..sort());
final includeDetected = argResults!['include-detected'] as bool;
final include = dynamicReads.isEmpty
? (usedKeys.toList()..sort())
: (includeDetected ? detected : null);
bool ships(String key) =>
!AppConfigSettings.defaultExclude.contains(key) &&
(include == null || include.contains(key));
final leftOut = (allEnvKeys.where((k) => !ships(k)).toList()..sort());
final nativeReaders =
AppConfigService(projectDir, ConfigService(projectDir))
.nativeEnvReaders();
final secrets = <String>[];
for (final client in clients) {
for (final name in const ['.env', '.env_test']) {
final f = File(p.join(clientsDir.path, client, name));
if (!f.existsSync()) continue;
final values = ConfigService.parseEnvContent(f.readAsStringSync());
for (final e in values.entries) {
final why = SecretDetector.reason(e.key, e.value);
if (why == null) continue;
secrets.add('clients/$client/$name: ${e.key} ($why) → '
'${!ships(e.key) ? 'not read by the app, so it stays out after migrating' : include == null ? 'no allow-list yet, so it would still be compiled in' : 'the app reads it, so it would still be compiled in'}');
}
}
}
_printPlan(
output: output,
className: className,
seedClient: seedClient,
files: fileResults,
envAssets: envAssets,
secrets: secrets,
include: include,
detected: detected,
dynamicReads: dynamicReads,
leftOut: leftOut,
nativeReaders: nativeReaders,
);
final blocked = fileResults.entries.where((e) => e.value.blocked).toList();
if (!apply) {
Logger.phase('Dry Run');
Logger.info(
'Nothing was changed. Review the plan, commit your work, then run:');
Logger.info(' udara_cli migrate-config ${[
..._planFlags(),
'--apply',
].join(' ')}');
return;
}
// ---- Apply ------------------------------------------------------------
if (!(argResults!['allow-dirty'] as bool)) _requireCleanGit();
if (blocked.isNotEmpty && !(argResults!['force'] as bool)) {
throw BuildException(
'${blocked.length} file(s) need manual changes first (marked ✗ above).',
fix:
'Fix them by hand and re-run, or pass --force to migrate the rest anyway.',
);
}
Logger.phase('Migrating');
_writeSettings(output, className, include);
Logger.success('udara.yaml: app_config mode set to generated');
final config = ConfigService(projectDir);
final appConfig = AppConfigService(projectDir, config);
await appConfig.write(
client: seedClient,
envFile: seedEnv,
isTest: false,
trackForCleanup: false);
Logger.success('Generated $output from clients/$seedClient/.env');
var rewritten = 0;
for (final entry in fileResults.entries) {
if (!entry.value.changed) continue;
File(p.join(projectDir, entry.key)).writeAsStringSync(entry.value.source);
rewritten++;
}
if (rewritten > 0) Logger.success('Rewrote $rewritten Dart file(s)');
if (envAssets.isNotEmpty) {
final editor = YamlEditor(pubspec.readAsStringSync());
final remaining = assets.where((a) => !_isEnvAsset(a)).toList();
if (remaining.isEmpty) {
editor.remove(['flutter', 'assets']);
} else {
editor.update(['flutter', 'assets'], remaining);
}
pubspec.writeAsStringSync(editor.toString());
Logger.success(
'pubspec.yaml: removed ${envAssets.join(', ')} from assets');
}
await config.ensureGitignoreEntries(
const ['clients/*/.secrets*', 'clients/*/*.secrets']);
// A `**/*.g.dart` rule for build_runner output would keep the class
// out of git.
await appConfig.ensureOutputTracked();
final stillUsesDotenv = _dartFiles(output).any((f) {
final src = f.readAsStringSync();
return src.contains('package:flutter_dotenv/');
});
Logger.phase('Next Steps');
Logger.info(
'1. flutter analyze (check the rewritten code compiles)');
Logger.info('2. udara_cli doctor (checks the new setup)');
Logger.info('3. udara_cli build --client $seedClient');
if (!stillUsesDotenv) {
Logger.info(
'4. flutter pub remove flutter_dotenv (nothing imports it any more)');
}
if (secrets.isNotEmpty) {
Logger.info(
'Move real secrets out of .env into clients/<client>/.secrets (see above).');
}
Logger.info(
'To undo: git checkout . && git clean -fd lib (or set app_config mode: dotenv).');
}