255 lines
7.4 KiB
Dart
255 lines
7.4 KiB
Dart
|
|
// ============================================================================
|
||
|
|
// 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<String> 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 <sync|check>');
|
||
|
|
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<String, String> dependencies;
|
||
|
|
final Map<String, String> 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 = <String, String>{};
|
||
|
|
final devDeps = <String, String>{};
|
||
|
|
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<File> _findPubspecs(Directory root) {
|
||
|
|
final result = <File>[];
|
||
|
|
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<File> 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<File> pubspecs, _Catalog catalog) {
|
||
|
|
final drifts = <String>[];
|
||
|
|
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<String> drifts;
|
||
|
|
_ProcessResult(this.content, this.replacements, this.drifts);
|
||
|
|
}
|
||
|
|
|
||
|
|
_ProcessResult _processPubspec(String content, _Catalog catalog) {
|
||
|
|
final lines = content.split('\n');
|
||
|
|
final newLines = <String>[];
|
||
|
|
String? section;
|
||
|
|
var replacements = 0;
|
||
|
|
final drifts = <String>[];
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|