// ============================================================================ // sync_deps.dart // // Single source of truth for external dependency versions. // Reads dependencies.yaml and propagates versions to all pubspec.yaml files. // // Usage: // dart run tool/sync_deps.dart sync - Update all pubspec.yaml from dependencies.yaml // dart run tool/sync_deps.dart check - Verify all pubspec.yaml are in sync (CI-friendly) // ============================================================================ import 'dart:io'; const _depsFileName = 'dependencies.yaml'; void main(List args) async { if (args.isEmpty) { _printUsage(); exit(1); } final command = args.first; final repoRoot = _findRepoRoot(); final depsFile = File('${repoRoot.path}/$_depsFileName'); if (!depsFile.existsSync()) { stderr.writeln('error: $_depsFileName not found at ${depsFile.path}'); exit(1); } final catalog = _parseDependenciesYaml(depsFile.readAsStringSync()); final pubspecs = _findPubspecs(repoRoot); switch (command) { case 'sync': _syncCommand(pubspecs, catalog); case 'check': _checkCommand(pubspecs, catalog); default: _printUsage(); exit(1); } } void _printUsage() { stderr.writeln('Usage: dart run tool/sync_deps.dart '); stderr.writeln(' sync - Propagate versions from $_depsFileName to all pubspec.yaml'); stderr.writeln(' check - Verify all pubspec.yaml are in sync (exit 1 if drift detected)'); } Directory _findRepoRoot() { var dir = Directory.current; while (dir.parent.path != dir.path) { if (File('${dir.path}/$_depsFileName').existsSync() && File('${dir.path}/melos.yaml').existsSync() == false && File('${dir.path}/pubspec.yaml').existsSync()) { return dir; } if (File('${dir.path}/$_depsFileName').existsSync()) return dir; dir = dir.parent; } return Directory.current; } class _Catalog { final Map dependencies; final Map devDependencies; _Catalog(this.dependencies, this.devDependencies); String? versionFor(String depName, {required bool isDev}) { // Dev deps may also be looked up in regular deps as fallback return (isDev ? devDependencies[depName] : dependencies[depName]) ?? dependencies[depName] ?? devDependencies[depName]; } } _Catalog _parseDependenciesYaml(String content) { final lines = content.split('\n'); final deps = {}; final devDeps = {}; String? section; for (final raw in lines) { final line = raw.replaceAll('\r', ''); final trimmed = line.trim(); if (trimmed.isEmpty || trimmed.startsWith('#')) continue; // Section headers (no leading space) if (line == 'dependencies:') { section = 'deps'; continue; } if (line == 'dev_dependencies:') { section = 'dev_deps'; continue; } // Top-level key resets section if (RegExp(r'^[a-zA-Z]').hasMatch(line)) { section = null; continue; } // Match ` pkg: version` (allow comments at end) final match = RegExp(r'^\s\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(\S+)').firstMatch(line); if (match == null) continue; final pkg = match.group(1)!; final ver = match.group(2)!; if (section == 'deps') { deps[pkg] = ver; } else if (section == 'dev_deps') { devDeps[pkg] = ver; } } return _Catalog(deps, devDeps); } List _findPubspecs(Directory root) { final result = []; void scan(Directory dir) { final name = dir.path.split('/').last; if (const {'.git', '.dart_tool', 'build', 'node_modules', '.fvm', 'Pods', 'example'} .contains(name)) { return; } for (final entity in dir.listSync(followLinks: false)) { if (entity is File && entity.path.endsWith('/pubspec.yaml')) { // Skip root pubspec if (entity.path == '${root.path}/pubspec.yaml') continue; result.add(entity); } else if (entity is Directory) { scan(entity); } } } scan(root); return result; } void _syncCommand(List pubspecs, _Catalog catalog) { var totalReplacements = 0; var filesChanged = 0; for (final file in pubspecs) { final content = file.readAsStringSync(); final result = _processPubspec(content, catalog); if (result.replacements > 0) { file.writeAsStringSync(result.content); filesChanged++; totalReplacements += result.replacements; print(' updated: ${file.path} (${result.replacements} deps)'); } } print(''); print('Synced $totalReplacements dependencies across $filesChanged files.'); if (totalReplacements == 0) { print('All packages already in sync.'); } } void _checkCommand(List pubspecs, _Catalog catalog) { final drifts = []; for (final file in pubspecs) { final content = file.readAsStringSync(); final result = _processPubspec(content, catalog); if (result.drifts.isNotEmpty) { for (final drift in result.drifts) { drifts.add('${file.path}: ${drift}'); } } } if (drifts.isEmpty) { print('OK: All pubspec.yaml files are in sync with $_depsFileName'); exit(0); } else { stderr.writeln('FAIL: Found ${drifts.length} dependency drifts:'); stderr.writeln(''); for (final drift in drifts) { stderr.writeln(' $drift'); } stderr.writeln(''); stderr.writeln('Run `melos run sync-deps` to fix.'); exit(1); } } class _ProcessResult { final String content; final int replacements; final List drifts; _ProcessResult(this.content, this.replacements, this.drifts); } _ProcessResult _processPubspec(String content, _Catalog catalog) { final lines = content.split('\n'); final newLines = []; String? section; var replacements = 0; final drifts = []; for (final line in lines) { final stripped = line.replaceAll('\r', ''); // Section detection if (stripped == 'dependencies:') { section = 'deps'; newLines.add(stripped); continue; } if (stripped == 'dev_dependencies:') { section = 'dev_deps'; newLines.add(stripped); continue; } if (stripped == 'dependency_overrides:') { section = 'overrides'; newLines.add(stripped); continue; } // Top-level resets section if (RegExp(r'^[a-zA-Z]').hasMatch(stripped)) { section = null; newLines.add(stripped); continue; } if (section == 'deps' || section == 'dev_deps') { // Match ` pkg_name: version` where version is ^x.y.z, x.y.z, etc. final match = RegExp(r'^(\s+)([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(\^?[\d.]+(?:[+\-]\S+)?)\s*$') .firstMatch(stripped); if (match != null) { final indent = match.group(1)!; final depName = match.group(2)!; final currentVer = match.group(3)!; // Don't touch SDK declarations or special keys if (depName == 'sdk' || depName == 'flutter') { newLines.add(stripped); continue; } final targetVer = catalog.versionFor(depName, isDev: section == 'dev_deps'); if (targetVer != null && targetVer != currentVer) { newLines.add('$indent$depName: $targetVer'); replacements++; drifts.add('$depName: $currentVer (expected $targetVer)'); continue; } } } newLines.add(stripped); } return _ProcessResult(newLines.join('\n'), replacements, drifts); }