import 'dart:io'; const _fallbackVersion = '0.0.1'; final _versionPattern = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$'); typedef _Release = ({ String platform, String prefix, String source, _Version version, }); final class _Version implements Comparable<_Version> { const _Version(this.major, this.minor, this.patch); factory _Version.parse(String value) { final match = _versionPattern.firstMatch(value); if (match == null) { throw FormatException( "Unsupported version '$value'. Expected MAJOR.MINOR.PATCH.", ); } return _Version( int.parse(match[1]!), int.parse(match[2]!), int.parse(match[3]!), ); } final int major; final int minor; final int patch; _Version get nextPatch => _Version(major, minor, patch + 1); int get buildNumber => major * 1000000 + minor * 1000 + patch; @override int compareTo(_Version other) { final majorComparison = major.compareTo(other.major); if (majorComparison != 0) return majorComparison; final minorComparison = minor.compareTo(other.minor); if (minorComparison != 0) return minorComparison; return patch.compareTo(other.patch); } @override String toString() => '$major.$minor.$patch'; } Future main(List arguments) async { try { await _run(arguments); } on FormatException catch (error) { stderr.writeln('error: ${error.message}'); stderr.writeln(_usage); exitCode = 2; } on StateError catch (error) { stderr.writeln('error: ${error.message}'); exitCode = 1; } } Future _run(List arguments) async { if (arguments.isEmpty || arguments.contains('--help')) { stdout.writeln(_usage); if (arguments.isEmpty) exitCode = 2; return; } final command = arguments.first; final options = _parseOptions(arguments.skip(1).toList()); final projectRoot = options.projectRoot ?? _defaultProjectRoot; switch (command) { case 'current': _expectOnePlatform(options); stdout.writeln( await _currentVersion(options.positional.single, projectRoot), ); case 'next': _expectOnePlatform(options); final release = await _nextRelease( options.positional.single, projectRoot, ); stdout.writeln( '${release.platform} ${release.version} ${release.source}', ); case 'build-number': if (options.positional.length != 1 || options.hasTagOptions) { throw const FormatException('build-number requires one version.'); } stdout.writeln(_Version.parse(options.positional.single).buildNumber); case 'tag': if (options.positional.length != 1) { throw const FormatException('tag requires one platform.'); } await _tag(options.positional.single, projectRoot, options); default: throw FormatException("Unknown command '$command'."); } } void _expectOnePlatform(_Options options) { if (options.positional.length != 1 || options.hasTagOptions) { throw const FormatException('current/next requires one platform.'); } } ({String platform, String prefix}) _resolvePlatform(String value) { return switch (value.trim().toLowerCase()) { 'windows' || 'pc' || 'macos' || 'darwin' => (platform: 'windows', prefix: 'v'), 'android' => (platform: 'android', prefix: 'android-v'), _ => throw FormatException( "Unsupported platform '$value'. Expected windows, pc, macos, or android.", ), }; } Future<_Version> _currentVersion(String platform, String projectRoot) async { final pubspec = _pubspecVersion(projectRoot); final latest = await _latestTag(platform, projectRoot); return latest == null || latest.version.compareTo(pubspec) < 0 ? pubspec : latest.version; } Future<_Release> _nextRelease(String platform, String projectRoot) async { final resolved = _resolvePlatform(platform); final pubspec = _pubspecVersion(projectRoot); final latest = await _latestTag(resolved.platform, projectRoot); if (latest == null || latest.version.compareTo(pubspec) < 0) { return ( platform: resolved.platform, prefix: resolved.prefix, source: 'pubspec:$pubspec', version: pubspec, ); } return ( platform: resolved.platform, prefix: resolved.prefix, source: latest.tag, version: latest.version.nextPatch, ); } _Version _pubspecVersion(String projectRoot) { final pubspec = File('$projectRoot${Platform.pathSeparator}pubspec.yaml'); if (!pubspec.existsSync()) return _Version.parse(_fallbackVersion); final match = RegExp( r'^version:\s*(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)\s*$', multiLine: true, ).firstMatch(pubspec.readAsStringSync()); return _Version.parse(match?[1] ?? _fallbackVersion); } Future<({String tag, _Version version})?> _latestTag( String platform, String projectRoot, ) async { final resolved = _resolvePlatform(platform); final result = await Process.run('git', [ 'tag', '--list', '${resolved.prefix}*', ], workingDirectory: projectRoot); if (result.exitCode != 0) return null; final pattern = RegExp( '^${RegExp.escape(resolved.prefix)}' r'(\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)$', ); ({String tag, _Version version})? latest; for (final tag in '${result.stdout}'.split(RegExp(r'\r?\n'))) { final match = pattern.firstMatch(tag.trim()); if (match == null) continue; final candidate = (tag: tag.trim(), version: _Version.parse(match[1]!)); if (latest == null || candidate.version.compareTo(latest.version) > 0) { latest = candidate; } } return latest; } Future _tag( String requestedPlatform, String projectRoot, _Options options, ) async { final platforms = requestedPlatform.toLowerCase() == 'both' ? const ['windows', 'android'] : [_resolvePlatform(requestedPlatform).platform]; for (final platform in platforms) { final release = await _nextRelease(platform, projectRoot); final tag = '${release.prefix}${release.version}'; final existing = await _git(['tag', '--list', tag], projectRoot); if ('${existing.stdout}'.trim().isNotEmpty) { throw StateError("Tag '$tag' already exists. Aborting."); } stdout.writeln('[${release.platform}] ${release.source} -> $tag'); if (options.dryRun) { stdout.writeln(' DryRun: skip create/push.'); continue; } await _git(['tag', tag], projectRoot); if (options.noPush) { stdout.writeln(' Created local tag (--no-push).'); continue; } await _git(['push', options.remote, tag], projectRoot); stdout.writeln(' Pushed to ${options.remote}.'); } } Future _git(List arguments, String projectRoot) async { final result = await Process.run( 'git', arguments, workingDirectory: projectRoot, ); if (result.exitCode != 0) { throw StateError( '${result.stderr}'.trim().isEmpty ? 'git ${arguments.join(' ')} failed with exit ${result.exitCode}.' : '${result.stderr}'.trim(), ); } return result; } _Options _parseOptions(List arguments) { final positional = []; String? projectRoot; var dryRun = false; var noPush = false; var remote = 'origin'; for (var index = 0; index < arguments.length; index++) { final argument = arguments[index]; switch (argument) { case '--dry-run': dryRun = true; case '--no-push': noPush = true; case '--project-root' || '--remote': if (++index >= arguments.length) { throw FormatException('$argument requires a value.'); } if (argument == '--project-root') { projectRoot = arguments[index]; } else { remote = arguments[index]; } default: if (argument.startsWith('--project-root=')) { projectRoot = argument.substring('--project-root='.length); } else if (argument.startsWith('--remote=')) { remote = argument.substring('--remote='.length); } else if (argument.startsWith('-')) { throw FormatException("Unknown option '$argument'."); } else { positional.add(argument); } } } if (remote.isEmpty) throw const FormatException('remote cannot be empty.'); return _Options(positional, projectRoot, dryRun, noPush, remote); } final class _Options { const _Options( this.positional, this.projectRoot, this.dryRun, this.noPush, this.remote, ); final List positional; final String? projectRoot; final bool dryRun; final bool noPush; final String remote; bool get hasTagOptions => dryRun || noPush || remote != 'origin'; } String get _defaultProjectRoot => File.fromUri(Platform.script).parent.parent.path; const _usage = '''Usage: dart scripts/version.dart current dart scripts/version.dart next dart scripts/version.dart build-number dart scripts/version.dart tag [--dry-run] [--no-push] [--remote=]''';