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 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).');
}