├── test ├── helpers │ └── helpers.dart └── lockpick_test.dart ├── .fvmrc ├── lib └── src │ ├── commands │ ├── commands.dart │ └── sync.dart │ ├── functions │ ├── functions.dart │ └── enums.dart │ ├── version.dart │ ├── models │ ├── models.dart │ ├── caret_syntax_preference.dart │ ├── dependency_change.dart │ ├── dependency_type.dart │ └── simple_dependency.dart │ ├── extensions │ ├── object_extensions.dart │ ├── string_extensions.dart │ ├── command_extensions.dart │ ├── extensions.dart │ ├── iterable_extensions.dart │ ├── arg_parser_extensions.dart │ └── directory_extensions.dart │ ├── command_runner.dart │ ├── logger.dart │ └── dart_cli.dart ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md ├── CODEOWNERS ├── workflows │ ├── ci.yaml │ └── lockpick.yaml └── PULL_REQUEST_TEMPLATE.md ├── cspell.json ├── analysis_options.yaml ├── example └── README.md ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── bin └── lockpick.dart ├── pubspec.yaml ├── README.md ├── CHANGELOG.md └── LICENSE /test/helpers/helpers.dart: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.fvmrc: -------------------------------------------------------------------------------- 1 | { 2 | "flutter": "3.32.0" 3 | } -------------------------------------------------------------------------------- /lib/src/commands/commands.dart: -------------------------------------------------------------------------------- 1 | export 'sync.dart'; 2 | -------------------------------------------------------------------------------- /lib/src/functions/functions.dart: -------------------------------------------------------------------------------- 1 | export 'enums.dart'; 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": ["lockpick", "pana", "writeln"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Every request must be reviewed and accepted by: 2 | 3 | * @jeroen-meijer -------------------------------------------------------------------------------- /lib/src/version.dart: -------------------------------------------------------------------------------- 1 | // Generated code. Do not modify. 2 | const packageVersion = '0.0.2'; 3 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | linter: 3 | rules: 4 | public_member_api_docs: false 5 | -------------------------------------------------------------------------------- /lib/src/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'caret_syntax_preference.dart'; 2 | export 'dependency_change.dart'; 3 | export 'dependency_type.dart'; 4 | export 'simple_dependency.dart'; 5 | -------------------------------------------------------------------------------- /lib/src/extensions/object_extensions.dart: -------------------------------------------------------------------------------- 1 | extension ObjectExtensions on T { 2 | /// Calls the provided [fn] on this object. 3 | R map(R Function(T) fn) => fn(this); 4 | } 5 | -------------------------------------------------------------------------------- /lib/src/extensions/string_extensions.dart: -------------------------------------------------------------------------------- 1 | extension StringExtensions on String { 2 | /// Returns this string if the string is not empty, otherwise returns [other]. 3 | String orIfEmpty(String other) { 4 | return isEmpty ? other : this; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | ```sh 4 | # Activate lockpick 5 | $ dart pub global activate lockpick 6 | 7 | # See usage information 8 | $ lockpick --help 9 | 10 | # Sync dependency versions in the current directory 11 | $ lockpick sync 12 | ``` 13 | -------------------------------------------------------------------------------- /lib/src/extensions/command_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/command_runner.dart'; 2 | 3 | extension CommandExtensions on Command { 4 | /// Indicates if the verbose flag was set. 5 | bool get isVerboseFlagSet => argResults!['verbose'] == true; 6 | } 7 | -------------------------------------------------------------------------------- /lib/src/extensions/extensions.dart: -------------------------------------------------------------------------------- 1 | export 'arg_parser_extensions.dart'; 2 | export 'command_extensions.dart'; 3 | export 'directory_extensions.dart'; 4 | export 'iterable_extensions.dart'; 5 | export 'object_extensions.dart'; 6 | export 'string_extensions.dart'; 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: noop 10 | run: echo 'noop' 11 | 12 | pana: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: noop 16 | run: echo 'noop' 17 | -------------------------------------------------------------------------------- /lib/src/extensions/iterable_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:lockpick/src/functions/functions.dart' as f; 2 | 3 | extension IterableExtensions on Iterable { 4 | /// Finds the first value of which the description matches the given [value]. 5 | T findEnumValue(String value) => f.findEnumValue(this, value); 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | pubspec.lock 5 | 6 | # Conventional directory for build outputs 7 | build/ 8 | 9 | # Directory created by dartdoc 10 | doc/api/ 11 | 12 | # Temporary Files 13 | .tmp/ 14 | 15 | # Files generated during tests 16 | .test_coverage.dart 17 | coverage/ 18 | 19 | # FVM Version Cache 20 | .fvm/ -------------------------------------------------------------------------------- /lib/src/extensions/arg_parser_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | 3 | extension ArgParserExtensions on ArgParser { 4 | /// Adds a 'verbose' flag to the parser. 5 | void addVerboseFlag() { 6 | addFlag( 7 | 'verbose', 8 | abbr: 'v', 9 | negatable: false, 10 | help: 11 | 'Print debug information. ' 12 | 'Can be used with any command.', 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/src/functions/enums.dart: -------------------------------------------------------------------------------- 1 | String describeEnum(dynamic obj) { 2 | if (obj == null) { 3 | return 'null'; 4 | } else { 5 | return obj.toString().split('.').last; 6 | } 7 | } 8 | 9 | T findEnumValue(Iterable options, String value) { 10 | for (final option in options) { 11 | if (describeEnum(option) == value) { 12 | return option; 13 | } 14 | } 15 | 16 | throw StateError( 17 | 'Could not find enum value "$value" ' 18 | 'in options: [${options.map(describeEnum).join(', ')}]', 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run sync command", 9 | "request": "launch", 10 | "type": "dart", 11 | "program": "bin/lockpick.dart", 12 | "args": [ 13 | "sync", 14 | "/Users/jeroen/Projects/work/breeze/flutter_dashboard", 15 | "--verbose" 16 | ] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/models/caret_syntax_preference.dart: -------------------------------------------------------------------------------- 1 | import 'package:lockpick/src/functions/functions.dart'; 2 | 3 | /// The preference for using caret syntax (^) for dependency versions. 4 | /// 5 | /// * [auto]: Automatically detect if a pubspec is using caret syntax. 6 | /// * [always]: Always use caret syntax. 7 | /// * [never]: Never use caret syntax. 8 | enum CaretSyntaxPreference { auto, always, never } 9 | 10 | extension NullableCaretSyntaxPreferenceExtensions on CaretSyntaxPreference? { 11 | /// Returns a description of this [CaretSyntaxPreference]. 12 | String describe() => describeEnum(this); 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "fix: " 5 | labels: bug 6 | --- 7 | 8 | **Description** 9 | A clear and concise description of what the bug is. 10 | 11 | **Steps To Reproduce** 12 | 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected Behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Additional Context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /lib/src/models/dependency_change.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:lockpick/src/models/models.dart'; 3 | 4 | class DependencyChange extends Equatable { 5 | const DependencyChange({ 6 | required this.name, 7 | required this.originalVersion, 8 | required this.newVersion, 9 | required this.type, 10 | }); 11 | 12 | final String name; 13 | final String originalVersion; 14 | final String newVersion; 15 | final DependencyType type; 16 | 17 | bool get hasChange => originalVersion.replaceAll('^', '') != newVersion; 18 | 19 | @override 20 | List get props => [name, originalVersion, newVersion, type]; 21 | } 22 | -------------------------------------------------------------------------------- /bin/lockpick.dart: -------------------------------------------------------------------------------- 1 | import 'package:lockpick/src/command_runner.dart'; 2 | import 'package:universal_io/io.dart'; 3 | 4 | Future main(List args) async { 5 | await _flushThenExit(await LockpickCommandRunner().run(args)); 6 | } 7 | 8 | /// Flushes the stdout and stderr streams, then exits the program with the given 9 | /// status code. 10 | /// 11 | /// This returns a Future that will never complete, since the program will have 12 | /// exited already. This is useful to prevent Future chains from proceeding 13 | /// after you've decided to exit. 14 | Future _flushThenExit(int status) { 15 | return Future.wait([stdout.close(), stderr.close()]) 16 | .then((_) => exit(status)); 17 | } 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Description 10 | 11 | 12 | 13 | ## Type of Change 14 | 15 | 16 | 17 | - [ ] ✨ New feature (non-breaking change which adds functionality) 18 | - [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) 19 | - [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) 20 | - [ ] 🧹 Code refactor 21 | - [ ] ✅ Build configuration change 22 | - [ ] 📝 Documentation 23 | - [ ] 🗑️ Chore 24 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: lockpick 2 | description: A CLI for syncing Dart dependency versions between pubspec.yaml and 3 | pubspec.lock files. 4 | version: 0.1.1 5 | homepage: https://github.com/jeroen-meijer/lockpick 6 | 7 | environment: 8 | sdk: '>= 3.8.0 <4.0.0' 9 | 10 | dependencies: 11 | args: ^2.7.0 12 | cli_dialog: ^0.5.0 13 | equatable: ^2.0.7 14 | indent: ^2.0.0 15 | io: ^1.0.5 16 | meta: ^1.17.0 17 | path: ^1.9.1 18 | pubspec_parse: ^1.5.0 19 | universal_io: ^2.2.2 20 | yaml: ^3.1.3 21 | 22 | dev_dependencies: 23 | build_runner: ^2.4.15 24 | build_verify: ^3.1.0 25 | build_version: ^2.1.1 26 | coverage: ^1.14.0 27 | mocktail: ^1.0.4 28 | test: ^1.26.2 29 | very_good_analysis: ^8.0.0 30 | 31 | executables: 32 | lockpick: lockpick 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lockpick 2 | 3 | [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] 4 | [![License: MIT][license_badge]][license_link] 5 | 6 | A CLI for syncing Dart dependency versions between pubspec.yaml and pubspec.lock files. 🔒 7 | 8 | ## Usage 9 | 10 | ```sh 11 | # Activate lockpick 12 | pub global activate lockpick 13 | 14 | # See usage information 15 | lockpick --help 16 | 17 | # Sync dependency versions in the current directory 18 | lockpick sync ./ 19 | ``` 20 | 21 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 22 | [license_link]: https://opensource.org/licenses/MIT 23 | [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg 24 | [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This file is a manually maintained list of changes for each release. Feel 4 | free to add your changes here when sending pull requests. 5 | 6 | ## 0.1.1 - 2025-05-28 7 | 8 | - fix: make pub get command use flutter and fvm if relevant 9 | 10 | ## 0.1.0 - 2025-05-26 11 | 12 | - chore: upgrade dependencies & Dart version, format all files 13 | 14 | ## 0.0.4 - 2023-07-07 15 | 16 | - fix: fix bug where lockpick would crash when run on a project with no dependencies 17 | - chore: upgrade dependencies & Dart version 18 | 19 | ## 0.0.3 - 2021-10-01 20 | 21 | - fix: fix bug where similarly named dependencies were incorrectly updated 22 | 23 | ## 0.0.2 - 2021-09-18 24 | 25 | - feat: improved cli implementation 26 | - feat: add help menu 27 | 28 | ## 0.0.1 - 2021-09-17 29 | 30 | - feat: initial proof of concept version 31 | -------------------------------------------------------------------------------- /lib/src/models/dependency_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:lockpick/src/functions/functions.dart'; 2 | 3 | /// The type/flavor of a dependency. 4 | /// 5 | /// Can either be [main] (for `dependencies`) or [dev] (for `dev_dependencies`). 6 | /// 7 | /// See also: 8 | /// * [getPubspecName], which return the approach pubspec-compatible name for a 9 | /// [DependencyType]. 10 | enum DependencyType { main, dev } 11 | 12 | extension NullableDependencyTypeExtensions on DependencyType? { 13 | /// Returns a description of this [DependencyType]. 14 | String describe() => describeEnum(this); 15 | 16 | /// Returns the pubspec name for this [DependencyType]. 17 | /// 18 | /// main: `"dependencies"` 19 | /// dev: `"dev_dependencies"` 20 | String getPubspecName() { 21 | switch (this) { 22 | case DependencyType.dev: 23 | return 'dev_dependencies'; 24 | case DependencyType.main: 25 | case null: 26 | return 'dependencies'; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/models/simple_dependency.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:lockpick/src/models/models.dart'; 3 | 4 | class SimpleDependency extends Equatable { 5 | const SimpleDependency({ 6 | required this.name, 7 | required this.version, 8 | this.type = DependencyType.main, 9 | }); 10 | 11 | final String name; 12 | final String version; 13 | final DependencyType type; 14 | 15 | @override 16 | List get props => [name, version, type]; 17 | 18 | SimpleDependency copyWith({ 19 | String? name, 20 | String? version, 21 | DependencyType? type, 22 | }) { 23 | return SimpleDependency( 24 | name: name ?? this.name, 25 | version: version ?? this.version, 26 | type: type ?? this.type, 27 | ); 28 | } 29 | 30 | Map toJson() { 31 | return { 32 | 'name': name, 33 | 'version': version, 34 | 'type': type.describe(), 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/extensions/directory_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:path/path.dart' as path; 2 | import 'package:universal_io/io.dart'; 3 | 4 | extension DirectoryExtensions on Directory { 5 | /// Indicates whether this directory is the current working directory. 6 | bool get isCurrent => absolute.path == Directory.current.absolute.path; 7 | 8 | /// Returns whether a file with the given [name] name exists in this 9 | /// directory. 10 | bool containsFileSync(String name) { 11 | final entities = listSync(); 12 | 13 | for (final entity in entities) { 14 | if (entity is File) { 15 | final fileName = path.basename(entity.path); 16 | if (fileName == name) { 17 | return true; 18 | } 19 | } 20 | } 21 | 22 | return false; 23 | } 24 | 25 | /// Returns whether this directory contains all files with the given 26 | /// [names]. 27 | bool containsFilesSync(List names) { 28 | for (final file in names) { 29 | if (!containsFileSync(file)) { 30 | return false; 31 | } 32 | } 33 | 34 | return true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jeroen Meijer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/lockpick.yaml: -------------------------------------------------------------------------------- 1 | name: lockpick 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/lockpick.yaml' 7 | - 'lib/**' 8 | - 'test/**' 9 | - 'pubspec.yaml' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | container: 15 | image: dart:3.0.5 16 | 17 | steps: 18 | - uses: actions/checkout@v3.5.3 19 | 20 | - name: Install Dependencies 21 | run: dart pub get 22 | 23 | - name: Format 24 | run: dart format --set-exit-if-changed lib test 25 | 26 | - name: Analyze 27 | run: dart analyze --fatal-infos --fatal-warnings . 28 | 29 | - name: Run tests 30 | run: dart test --chain-stack-traces --coverage=coverage && dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib 31 | 32 | # - name: Check Code Coverage 33 | # uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 34 | # with: 35 | # path: coverage/lcov.info 36 | # exclude: '**/*.g.dart **/*.gen.dart' 37 | 38 | pana: 39 | runs-on: ubuntu-latest 40 | container: 41 | image: dart:3.0.5 42 | 43 | steps: 44 | - uses: actions/checkout@v3.5.3 45 | 46 | - name: Install Dependencies 47 | run: | 48 | dart pub get 49 | dart pub global activate pana 0.22.21 50 | 51 | - name: Verify Pub Score 52 | run: | 53 | echo "Running pana..." 54 | PANA=$(dart pub global run pana . --no-warning); PANA_SCORE=$(echo $PANA | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p") 55 | echo "Done. Score: $PANA_SCORE" 56 | if [[ $PANA_SCORE != "160/160" ]]; then 57 | echo "Pana score is not 160/160. Failing build." 58 | exit 1 59 | fi 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeBackground": "#3b3347", 4 | "titleBar.activeForeground": "#ffffff", 5 | "titleBar.inactiveBackground": "#3b3347", 6 | "titleBar.inactiveForeground": "#ffffff90", 7 | "statusBarItem.warningBackground": "#3b3347", 8 | "statusBarItem.warningForeground": "#ffffff", 9 | "statusBarItem.warningHoverBackground": "#3b3347", 10 | "statusBarItem.warningHoverForeground": "#ffffff90", 11 | "statusBarItem.remoteBackground": "#3b3347", 12 | "statusBarItem.remoteForeground": "#ffffff", 13 | "statusBarItem.remoteHoverBackground": "#3b3347", 14 | "statusBarItem.remoteHoverForeground": "#ffffff90", 15 | "focusBorder": "#3b334799", 16 | "progressBar.background": "#3b3347", 17 | "textLink.foreground": "#7b7387", 18 | "textLink.activeForeground": "#888094", 19 | "selection.background": "#2e263a", 20 | "activityBarBadge.background": "#3b3347", 21 | "activityBarBadge.foreground": "#ffffff", 22 | "activityBar.activeBorder": "#3b3347", 23 | "list.highlightForeground": "#3b3347", 24 | "list.focusAndSelectionOutline": "#3b334799", 25 | "button.background": "#3b3347", 26 | "button.foreground": "#ffffff", 27 | "button.hoverBackground": "#484054", 28 | "tab.activeBorderTop": "#484054", 29 | "pickerGroup.foreground": "#484054", 30 | "list.activeSelectionBackground": "#3b33474d", 31 | "panelTitle.activeBorder": "#484054" 32 | }, 33 | "window.title": "lockpick", 34 | "dart.flutterSdkPath": ".fvm/versions/3.32.0", 35 | "dart.sdkPath": ".fvm/versions/3.32.0/bin/cache/dart-sdk", 36 | "projectColors.name": "lockpick", 37 | "projectColors.mainColor": "#3b3347", 38 | "projectColors.isActivityBarColored": false, 39 | "projectColors.isTitleBarColored": true, 40 | "projectColors.isStatusBarColored": false, 41 | "projectColors.isProjectNameColored": true, 42 | "projectColors.isActiveItemsColored": true, 43 | "projectColors.setWindowTitle": true 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/command_runner.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | import 'package:io/ansi.dart'; 4 | import 'package:io/io.dart'; 5 | import 'package:lockpick/src/commands/commands.dart'; 6 | import 'package:lockpick/src/extensions/extensions.dart'; 7 | import 'package:lockpick/src/logger.dart'; 8 | import 'package:lockpick/src/version.dart'; 9 | 10 | /// {@template lockpick_command_runner} 11 | /// A CLI for syncing Dart dependency versions between pubspec.yaml and 12 | /// pubspec.lock files. 13 | /// {@endtemplate} 14 | class LockpickCommandRunner extends CommandRunner { 15 | /// {@macro lockpick_command_runner} 16 | LockpickCommandRunner({Logger? logger}) 17 | : _logger = logger ?? Logger(), 18 | super( 19 | 'lockpick', 20 | 'A CLI for syncing Dart dependency versions ' 21 | 'between pubspec.yaml and pubspec.lock files. 🔒', 22 | ) { 23 | argParser 24 | ..addFlag( 25 | 'version', 26 | negatable: false, 27 | help: 'Print the current version of lockpick.', 28 | ) 29 | ..addVerboseFlag(); 30 | addCommand(SyncCommand(logger: logger)); 31 | } 32 | 33 | /// Standard timeout duration for the CLI. 34 | static const timeout = Duration(milliseconds: 500); 35 | 36 | final Logger _logger; 37 | 38 | @override 39 | Future run(Iterable args) async { 40 | try { 41 | if (args.isEmpty) { 42 | throw UsageException('No command specified.', ''); 43 | } else { 44 | return await runCommand(parse(args)) ?? ExitCode.success.code; 45 | } 46 | } on FormatException catch (e) { 47 | _logger 48 | ..err(e.message) 49 | ..info('') 50 | ..info(usage); 51 | return ExitCode.usage.code; 52 | } on UsageException catch (e) { 53 | _logger 54 | ..err(e.message) 55 | ..info('') 56 | ..info(usage); 57 | return ExitCode.usage.code; 58 | // We don't care what error happened, we just want to log it. 59 | // ignore: avoid_catches_without_on_clauses 60 | } catch (e, st) { 61 | _logger 62 | ..err(styleBold.wrap('Unexpected error occurred')) 63 | ..err(e.toString()) 64 | ..err(lightGray.wrap(st.toString())); 65 | return ExitCode.software.code; 66 | } 67 | } 68 | 69 | @override 70 | Future runCommand(ArgResults topLevelResults) async { 71 | if (topLevelResults['version'] == true) { 72 | _logger.info('lockpick version: $packageVersion'); 73 | return ExitCode.success.code; 74 | } else { 75 | final exitCode = await super.runCommand(topLevelResults); 76 | if (exitCode == ExitCode.success.code) { 77 | _logger.success('Done!'); 78 | } 79 | return exitCode; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/logger.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'dart:io'; 4 | import 'package:io/ansi.dart'; 5 | 6 | /// A basic Logger which wraps [print] and applies various styles. 7 | class Logger { 8 | static const List _progressAnimation = [ 9 | '⠋', 10 | '⠙', 11 | '⠹', 12 | '⠸', 13 | '⠼', 14 | '⠴', 15 | '⠦', 16 | '⠧', 17 | '⠇', 18 | '⠏', 19 | ]; 20 | 21 | /// Indicates if verbose [debug] messages should be logged. 22 | static bool verboseEnabled = false; 23 | 24 | final _queue = []; 25 | 26 | final _stopwatch = Stopwatch(); 27 | Timer? _timer; 28 | int _index = 0; 29 | 30 | /// Flushes internal message queue. 31 | void flush([void Function(String?)? print]) { 32 | final writeln = print ?? info; 33 | for (final message in _queue) { 34 | writeln(message); 35 | } 36 | _queue.clear(); 37 | } 38 | 39 | /// Writes a message to stdout. 40 | void log(String? message) => stdout.writeln(message); 41 | 42 | /// Writes info message to stdout. 43 | void info(String? message) => stdout.writeln(message); 44 | 45 | /// Writes delayed message to stdout. 46 | void delayed(String? message) => _queue.add(message); 47 | 48 | /// Writes debug message to stdout. 49 | void debug(String? message) { 50 | if (verboseEnabled) { 51 | stdout.writeln(lightGray.wrap(message)); 52 | } 53 | } 54 | 55 | /// Writes progress message to stdout. 56 | void Function([String? update]) progress(String message) { 57 | _stopwatch 58 | ..reset() 59 | ..start(); 60 | _timer?.cancel(); 61 | _timer = Timer.periodic(const Duration(milliseconds: 80), (t) { 62 | _index++; 63 | final char = _progressAnimation[_index % _progressAnimation.length]; 64 | stdout.write( 65 | '''${lightGreen.wrap('\b${'\b' * (message.length + 4)}$char')} $message...''', 66 | ); 67 | }); 68 | return ([String? update]) { 69 | _stopwatch.stop(); 70 | final time = (_stopwatch.elapsed.inMilliseconds / 1000.0).toStringAsFixed( 71 | 1, 72 | ); 73 | stdout.write( 74 | '''${lightGreen.wrap('\b${'\b' * (message.length + 4)}✓')} ${update ?? message} (${time}s)\n''', 75 | ); 76 | _timer?.cancel(); 77 | }; 78 | } 79 | 80 | /// Writes error message to stdout. 81 | void err(String? message) => stdout.writeln(lightRed.wrap(message)); 82 | 83 | /// Writes alert message to stdout. 84 | void alert(String? message) { 85 | stdout.writeln(lightCyan.wrap(styleBold.wrap(message))); 86 | } 87 | 88 | /// Writes detail message to stdout. 89 | void detail(String? message) => stdout.writeln(darkGray.wrap(message)); 90 | 91 | /// Writes warning message to stdout. 92 | void warn(String? message) { 93 | stdout.writeln(yellow.wrap(styleBold.wrap('[WARN] $message'))); 94 | } 95 | 96 | /// Writes success message to stdout. 97 | void success(String? message) => stdout.writeln(lightGreen.wrap(message)); 98 | 99 | /// Prompts user and returns response. 100 | String prompt(String? message) { 101 | stdout.write('$message'); 102 | return stdin.readLineSync() ?? ''; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/dart_cli.dart: -------------------------------------------------------------------------------- 1 | import 'package:indent/indent.dart'; 2 | import 'package:io/ansi.dart'; 3 | import 'package:lockpick/src/logger.dart'; 4 | import 'package:path/path.dart' as path; 5 | import 'package:universal_io/io.dart'; 6 | import 'package:yaml/yaml.dart'; 7 | 8 | /// {@template dart_cli} 9 | /// A simple wrapper around the Dart CLI. 10 | /// {@endtemplate} 11 | class DartCli { 12 | /// {@macro dart_cli} 13 | DartCli({Logger? logger}) : _logger = logger ?? Logger(); 14 | 15 | final Logger _logger; 16 | 17 | /// Returns true if the project at the given [path] is a Flutter project. 18 | /// 19 | /// The provided [path] be a directory and contain a `pubspec.yaml` file. 20 | /// This file will be checked for the following string: 21 | /// ```yaml 22 | /// # ... 23 | /// flutter: 24 | /// sdk: 25 | /// # ... 26 | /// ``` 27 | Future isFlutterProject(String projectPath) async { 28 | final pubspecContents = await File( 29 | path.join(projectPath, 'pubspec.yaml'), 30 | ).readAsString(); 31 | final pubspecYamlMap = loadYaml(pubspecContents); 32 | 33 | if (pubspecYamlMap case {'dependencies': {'flutter': _?}}) { 34 | return true; 35 | } else { 36 | return false; 37 | } 38 | } 39 | 40 | /// Returns true if the project at the given [path] is an FVM project. 41 | /// 42 | /// The provided [path] be a directory and contain a `.fvm` directory with 43 | /// an `fvm_config.json` file. 44 | bool isFvmProject(String projectPath) { 45 | final fvmRcPath = path.join(projectPath, '.fvmrc'); 46 | final fvmConfigPath = path.join(projectPath, '.fvm', 'fvm_config.json'); 47 | 48 | return [fvmRcPath, fvmConfigPath].any((p) => File(p).existsSync()); 49 | } 50 | 51 | /// Runs `dart pub upgrade` or `flutter pub upgrade`. 52 | Future pubUpgrade({String? workingDirectory}) async { 53 | await runDartOrFlutterCommand([ 54 | 'pub', 55 | 'upgrade', 56 | ], workingDirectory: workingDirectory); 57 | } 58 | 59 | /// Runs `dart pub get` or `flutter pub get`. 60 | Future pubGet({String? workingDirectory}) async { 61 | return runDartOrFlutterCommand([ 62 | 'pub', 63 | 'get', 64 | ], workingDirectory: workingDirectory); 65 | } 66 | 67 | /// Runs the given Flutter or Dart command. 68 | /// 69 | /// - If this project uses FVM, the command will be run with `fvm`. 70 | /// - If this project is a Flutter project, the command will be run with 71 | /// `flutter`. Otherwise, it will be run with `dart`. 72 | Future runDartOrFlutterCommand( 73 | List commandParts, { 74 | String? workingDirectory, 75 | }) async { 76 | final useFlutter = await isFlutterProject(workingDirectory ?? '.'); 77 | final useFvm = isFvmProject(workingDirectory ?? '.'); 78 | final fullCommandParts = [ 79 | if (useFvm) 'fvm', 80 | if (useFlutter) 'flutter' else 'dart', 81 | ...commandParts, 82 | ]; 83 | 84 | await _run( 85 | fullCommandParts.first, 86 | fullCommandParts.skip(1).toList(), 87 | workingDirectory: workingDirectory, 88 | ); 89 | } 90 | 91 | Future _run( 92 | String cmd, 93 | List args, { 94 | String? workingDirectory, 95 | }) async { 96 | final fullCommand = [cmd, ...args].join(' '); 97 | final stopProgress = _logger.progress('Running `$fullCommand`...'); 98 | 99 | final result = await Process.run( 100 | cmd, 101 | args, 102 | workingDirectory: workingDirectory, 103 | runInShell: true, 104 | ); 105 | stopProgress(); 106 | 107 | if (result.exitCode != 0) { 108 | final values = { 109 | 'Standard out': result.stdout.toString().trim(), 110 | 'Standard error': result.stderr.toString().trim(), 111 | }..removeWhere((k, v) => v.isEmpty); 112 | 113 | var message = 'Unknown error'; 114 | if (values.isNotEmpty) { 115 | message = values.entries 116 | .map((e) => '${e.key}\n${e.value.indent(2)}') 117 | .join('\n\n'); 118 | } 119 | 120 | _logger.err(styleBold.wrap('Error while running `$fullCommand`')); 121 | throw ProcessException(cmd, args, message, result.exitCode); 122 | } 123 | 124 | return result; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/lockpick_test.dart: -------------------------------------------------------------------------------- 1 | // We want to explicitly ignore const constructors in this file because 2 | // we need to test their behavior, and constructors won't be run when called in 3 | // a const context. 4 | // ignore_for_file: prefer_const_constructors 5 | import 'package:args/command_runner.dart'; 6 | import 'package:io/ansi.dart'; 7 | import 'package:io/io.dart'; 8 | import 'package:lockpick/src/command_runner.dart'; 9 | import 'package:lockpick/src/logger.dart'; 10 | import 'package:mocktail/mocktail.dart'; 11 | import 'package:test/test.dart'; 12 | 13 | class MockLogger extends Mock implements Logger {} 14 | 15 | const expectedUsage = ''' 16 | A CLI for syncing Dart dependency versions between pubspec.yaml and pubspec.lock files. 🔒 17 | 18 | Usage: lockpick [arguments] 19 | 20 | Global options: 21 | -h, --help Print this usage information. 22 | --version Print the current version of lockpick. 23 | -v, --verbose Print debug information. Can be used with any command. 24 | 25 | Available commands: 26 | sync lockpick sync [project_path] 27 | Syncs dependencies between a pubspec.yaml and a pubspec.lock file. Will run "[flutter] pub get" and "[flutter] pub upgrade" in the specified project path before syncing. 28 | 29 | Run "lockpick help " for more information about a command.'''; 30 | 31 | void main() { 32 | group('Lockpick', () { 33 | late Logger logger; 34 | 35 | // String? runningProgress; 36 | // final stoppedProgresses = []; 37 | 38 | setUp(() { 39 | logger = MockLogger(); 40 | when(() => logger.progress(any())).thenReturn(([_]) {}); 41 | // when(() => logger.progress(captureAny())).thenAnswer((_) { 42 | // final progress = _.positionalArguments.single as String; 43 | // runningProgress = progress; 44 | // return ([_]) { 45 | // runningProgress = null; 46 | // stoppedProgresses.add(progress); 47 | // }; 48 | // }); 49 | }); 50 | 51 | LockpickCommandRunner buildSubject() { 52 | return LockpickCommandRunner(logger: logger); 53 | } 54 | 55 | group('constructor', () { 56 | test('works properly', () { 57 | expect(buildSubject, returnsNormally); 58 | }); 59 | 60 | test('can be instantiated without an explicit logger instance', () { 61 | expect(LockpickCommandRunner.new, returnsNormally); 62 | }); 63 | }); 64 | 65 | group('run', () { 66 | test('handles FormatException', () async { 67 | final subject = buildSubject(); 68 | 69 | const exception = FormatException('oops'); 70 | var isFirstInvocation = true; 71 | when(() => logger.info(any())).thenAnswer((_) { 72 | if (isFirstInvocation) { 73 | isFirstInvocation = false; 74 | throw exception; 75 | } 76 | }); 77 | 78 | final result = await subject.run(['--version']); 79 | expect(result, equals(ExitCode.usage.code)); 80 | verify(() => logger.err(exception.message)).called(1); 81 | verify(() => logger.info(subject.usage)).called(1); 82 | }); 83 | 84 | test('handles UsageException', () async { 85 | final subject = buildSubject(); 86 | 87 | final exception = UsageException('oops!', subject.usage); 88 | var isFirstInvocation = true; 89 | when(() => logger.info(any())).thenAnswer((_) { 90 | if (isFirstInvocation) { 91 | isFirstInvocation = false; 92 | throw exception; 93 | } 94 | }); 95 | 96 | final result = await subject.run(['--version']); 97 | expect(result, equals(ExitCode.usage.code)); 98 | verify(() => logger.err(exception.message)).called(1); 99 | verify(() => logger.info(subject.usage)).called(1); 100 | }); 101 | 102 | test('handles no command', () async { 103 | final result = await buildSubject().run([]); 104 | 105 | expect(result, equals(ExitCode.usage.code)); 106 | verifyInOrder([ 107 | () => logger.err('No command specified.'), 108 | () => logger.info(''), 109 | () => logger.info(expectedUsage), 110 | ]); 111 | }); 112 | 113 | test('handles unexpected errors', () async { 114 | final subject = buildSubject(); 115 | 116 | final exception = Exception('oops'); 117 | when(() => logger.info(any())).thenThrow(exception); 118 | 119 | final result = await subject.run(['--version']); 120 | expect(result, ExitCode.software.code); 121 | 122 | verifyInOrder([ 123 | () => logger.err(styleBold.wrap('Unexpected error occurred')), 124 | () => logger.err(exception.toString()), 125 | () => logger.err( 126 | any(that: contains('#0 When.thenThrow.')), 127 | ), 128 | ]); 129 | }); 130 | 131 | // TODO(jeroen-meijer): Add more tests from https://github.com/felangel/mason/blob/master/test/command_runner_test.dart 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /lib/src/commands/sync.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | import 'package:args/command_runner.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:io/ansi.dart'; 5 | import 'package:lockpick/src/dart_cli.dart'; 6 | import 'package:lockpick/src/extensions/extensions.dart'; 7 | import 'package:lockpick/src/logger.dart'; 8 | import 'package:lockpick/src/models/models.dart'; 9 | import 'package:meta/meta.dart'; 10 | import 'package:path/path.dart' as path; 11 | import 'package:universal_io/io.dart'; 12 | import 'package:yaml/yaml.dart'; 13 | 14 | /// {@template sync_command} 15 | /// Syncs dependencies between a pubspec.yaml and a pubspec.lock file. 16 | /// 17 | /// Usage: `lockpick` or `lockpick sync` 18 | /// {@endtemplate} 19 | class SyncCommand extends Command { 20 | SyncCommand({DartCli? dartCli, Logger? logger}) 21 | : _dartCli = dartCli ?? DartCli(), 22 | _logger = logger ?? Logger() { 23 | argParser 24 | ..addVerboseFlag() 25 | ..addFlag( 26 | 'empty-only', 27 | abbr: 'e', 28 | help: 29 | 'Only add dependency versions in the pubspec.yaml for which ' 30 | 'there is no specific version set.', 31 | ) 32 | ..addOption( 33 | 'caret-syntax-preference', 34 | abbr: 'c', 35 | help: 'Specifies a preference for using caret syntax (^X.Y.Z).', 36 | allowed: [ 37 | CaretSyntaxPreference.auto.describe(), 38 | CaretSyntaxPreference.always.describe(), 39 | CaretSyntaxPreference.never.describe(), 40 | ], 41 | allowedHelp: { 42 | CaretSyntaxPreference.auto.describe(): 43 | 'Interpret caret syntax ' 44 | 'preference from existing dependencies in the pubspec.yaml file ' 45 | 'if possible. Will default to "always" if no trend is detected.', 46 | CaretSyntaxPreference.always.describe(): 'Always use caret syntax.', 47 | CaretSyntaxPreference.never.describe(): 'Never use caret syntax.', 48 | }, 49 | defaultsTo: CaretSyntaxPreference.auto.describe(), 50 | ) 51 | ..addMultiOption( 52 | 'dependency-types', 53 | aliases: ['types'], 54 | abbr: 't', 55 | help: 56 | 'Specifies what type of dependencies to sync. ' 57 | 'Will sync all dependencies by default.', 58 | allowed: [ 59 | DependencyType.main.describe(), 60 | DependencyType.dev.describe(), 61 | ], 62 | allowedHelp: { 63 | DependencyType.main.describe(): 64 | 'Sync main dependencies. Enabled by default.', 65 | DependencyType.dev.describe(): 66 | 'Sync dev_dependencies. Enabled by default.', 67 | }, 68 | valueHelp: 'main,dev', 69 | defaultsTo: [ 70 | DependencyType.main.describe(), 71 | DependencyType.dev.describe(), 72 | ], 73 | ) 74 | ..addFlag( 75 | 'dry-run', 76 | abbr: 'd', 77 | help: 78 | 'Only print the changes that would be made, but do not make them. ' 79 | 'When set, the exit code will indicate if a change would have been ' 80 | 'made. Useful for running in CI workflows.', 81 | ); 82 | } 83 | 84 | final DartCli _dartCli; 85 | final Logger _logger; 86 | 87 | @override 88 | String get description => 89 | 'Syncs dependencies between a pubspec.yaml and a pubspec.lock file. ' 90 | 'Will run "[flutter] pub get" and "[flutter] pub upgrade" in ' 91 | 'the specified project path before syncing.'; 92 | 93 | @override 94 | String get summary => '$invocation\n$description'; 95 | 96 | @override 97 | String get name => 'sync'; 98 | 99 | @override 100 | String get invocation => 'lockpick sync [project_path]'; 101 | 102 | /// [ArgResults] which can be overridden for testing. 103 | @visibleForTesting 104 | ArgResults? argResultOverrides; 105 | 106 | ArgResults get _argResults => argResultOverrides ?? argResults!; 107 | 108 | @override 109 | Future run() async { 110 | Logger.verboseEnabled = isVerboseFlagSet; 111 | 112 | _logger.debug('Running in verbose mode.'); 113 | 114 | final emptyOnly = _argResults['empty-only'] as bool; 115 | final dryRun = _argResults['dry-run'] == true; 116 | 117 | if (dryRun) { 118 | _logger.alert('Running in dry-run mode. No actual changes will be made.'); 119 | } 120 | 121 | final workingDirectory = _argResults.rest.isEmpty 122 | ? Directory.current 123 | : Directory(_argResults.rest.first); 124 | 125 | Directory.current = workingDirectory; 126 | 127 | if (!workingDirectory.existsSync()) { 128 | throw Exception('Given path "${workingDirectory.path}" does not exist.'); 129 | } else if (!workingDirectory.containsFileSync('pubspec.yaml')) { 130 | if (workingDirectory.isCurrent) { 131 | throw Exception( 132 | 'Current directory does not contain a pubspec.yaml file. ' 133 | 'Please specify a path to a Dart or Flutter project containing a ' 134 | 'pubspec.yaml file.', 135 | ); 136 | } else { 137 | throw Exception( 138 | 'Given path "${workingDirectory.path}" does not contain a ' 139 | 'pubspec.yaml file. Please specify a path to a Dart or Flutter ' 140 | 'project containing a pubspec.yaml file.', 141 | ); 142 | } 143 | } 144 | 145 | await _dartCli.pubGet(workingDirectory: workingDirectory.path); 146 | 147 | // Only run pub upgrade when not running in dry-run mode. 148 | if (dryRun) { 149 | _logger.info('Avoiding running pub upgrade in dry-run mode.'); 150 | } else { 151 | await _dartCli.pubUpgrade(workingDirectory: workingDirectory.path); 152 | } 153 | 154 | final caretSyntaxPreferenceString = 155 | _argResults['caret-syntax-preference'] as String; 156 | final caretSyntaxPreference = CaretSyntaxPreference.values.findEnumValue( 157 | caretSyntaxPreferenceString, 158 | ); 159 | 160 | final dependencyTypesString = 161 | _argResults['dependency-types'] as List; 162 | final dependencyTypes = dependencyTypesString 163 | .map( 164 | (dependencyTypeString) => 165 | DependencyType.values.findEnumValue(dependencyTypeString), 166 | ) 167 | .toList(growable: false); 168 | 169 | final args = SyncArgs( 170 | emptyOnly: emptyOnly, 171 | dryRun: dryRun, 172 | workingDirectory: workingDirectory, 173 | caretSyntaxPreference: caretSyntaxPreference, 174 | dependencyTypes: dependencyTypes, 175 | ); 176 | 177 | final result = await Sync(args: args, logger: _logger).run(); 178 | 179 | if (dryRun) { 180 | return result.didMakeChanges ? 1 : 0; 181 | } 182 | 183 | if (result.didMakeChanges) { 184 | await _dartCli.pubGet(workingDirectory: workingDirectory.path); 185 | } 186 | 187 | return 0; 188 | } 189 | } 190 | 191 | /// {@template sync_args} 192 | /// The arguments for the `lockpick sync` command. 193 | /// {@endtemplate} 194 | class SyncArgs extends Equatable { 195 | const SyncArgs({ 196 | required this.emptyOnly, 197 | required this.dryRun, 198 | required this.workingDirectory, 199 | required this.caretSyntaxPreference, 200 | required this.dependencyTypes, 201 | }); 202 | 203 | final bool emptyOnly; 204 | final bool dryRun; 205 | final Directory workingDirectory; 206 | final CaretSyntaxPreference caretSyntaxPreference; 207 | final List dependencyTypes; 208 | 209 | @override 210 | List get props => [ 211 | emptyOnly, 212 | dryRun, 213 | workingDirectory, 214 | caretSyntaxPreference, 215 | dependencyTypes, 216 | ]; 217 | } 218 | 219 | /// {@template sync_result} 220 | /// The result of running the `lockpick sync` command. 221 | /// {@endtemplate} 222 | class SyncResult extends Equatable { 223 | /// {@macro sync_result} 224 | const SyncResult({required this.didMakeChanges}); 225 | 226 | final bool didMakeChanges; 227 | 228 | @override 229 | List get props => [didMakeChanges]; 230 | } 231 | 232 | /// {@template sync} 233 | /// The runner for the `lockpick sync` command. 234 | /// {@endtemplate} 235 | @visibleForTesting 236 | class Sync { 237 | Sync({required SyncArgs args, Logger? logger}) 238 | : _args = args, 239 | _logger = logger ?? Logger(); 240 | 241 | final SyncArgs _args; 242 | final Logger _logger; 243 | 244 | File get _pubspecYamlFile => 245 | File(path.join(_args.workingDirectory.path, 'pubspec.yaml')); 246 | 247 | File get _pubspecLockFile => 248 | File(path.join(_args.workingDirectory.path, 'pubspec.lock')); 249 | 250 | Future> _loadYamlFile(File file) async { 251 | _logger.debug('Loading ${file.absolute.path}...'); 252 | final contents = await file.readAsString(); 253 | final yaml = loadYaml(contents) as YamlMap; 254 | _logger.debug('Loaded and parsed ${file.absolute.path}.'); 255 | return Map.from(yaml); 256 | } 257 | 258 | Future> _getDirectLockPackages() async { 259 | final lockMap = await _loadYamlFile(_pubspecLockFile); 260 | final lockPackages = Map.from( 261 | lockMap['packages'] as YamlMap, 262 | ).entries; 263 | final directPackages = lockPackages 264 | .where((entry) { 265 | final dependency = entry.value['dependency'] as String; 266 | return dependency.startsWith('direct'); 267 | }) 268 | .map( 269 | (entry) => SimpleDependency( 270 | name: entry.key, 271 | version: entry.value['version'] as String, 272 | type: entry.value['dependency'] == 'direct main' 273 | ? DependencyType.main 274 | : DependencyType.dev, 275 | ), 276 | ) 277 | .toList(growable: false); 278 | 279 | return directPackages; 280 | } 281 | 282 | Future> _getAllPubspecDependencies() async { 283 | final pubspecMap = await _loadYamlFile(_pubspecYamlFile); 284 | 285 | final dependencies = []; 286 | 287 | for (final type in DependencyType.values) { 288 | final depsForTypeYamlMap = 289 | pubspecMap[type.getPubspecName()] as YamlMap? ?? YamlMap(); 290 | final depsForType = Map.from(depsForTypeYamlMap).entries 291 | .where((entry) => entry.value == null || entry.value is String) 292 | .map( 293 | (entry) => SimpleDependency( 294 | name: entry.key, 295 | version: '${entry.value ?? ''}', 296 | type: type, 297 | ), 298 | ); 299 | dependencies.addAll(depsForType); 300 | } 301 | 302 | return dependencies; 303 | } 304 | 305 | Future _getCaretUsageTrend(List dependencies) async { 306 | final allDepsAmount = dependencies.length; 307 | final caretsUsed = dependencies 308 | .map((dep) => dep.version) 309 | .where((version) => version.startsWith('^')) 310 | .length; 311 | 312 | _logger.debug('$caretsUsed out of $allDepsAmount dependencies use carets.'); 313 | 314 | final caretUsageFactor = caretsUsed / allDepsAmount; 315 | return caretUsageFactor >= 0.5; 316 | } 317 | 318 | Future _applyChangeToContent({ 319 | required String contents, 320 | required DependencyChange change, 321 | required bool useCaretSyntax, 322 | }) async { 323 | final lines = contents.split('\n'); 324 | final dependencyTypeName = change.type.getPubspecName(); 325 | 326 | var hasEncounteredType = false; 327 | for (var i = 0; i < lines.length; i++) { 328 | final line = lines[i]; 329 | final trimmed = line.trim(); 330 | final colonIndex = trimmed.indexOf(':'); 331 | if (colonIndex != -1) { 332 | final key = trimmed.substring(0, colonIndex); 333 | if (key == dependencyTypeName) { 334 | hasEncounteredType = true; 335 | } else if (hasEncounteredType && key == change.name) { 336 | final firstCharIndex = line 337 | .split('') 338 | .indexWhere((char) => char != ' '); 339 | final whitespace = ' ' * firstCharIndex; 340 | final newVersionString = 341 | '${useCaretSyntax ? '^' : ''}${change.newVersion}'; 342 | lines[i] = '$whitespace${change.name}: $newVersionString'; 343 | } 344 | } 345 | } 346 | 347 | assert(hasEncounteredType, 'Could not find dependency type in pubspec.'); 348 | 349 | return lines.join('\n'); 350 | } 351 | 352 | Future run() async { 353 | _logger.debug('Running sync command with args: $_args'); 354 | 355 | void Function() stopProgress; 356 | 357 | stopProgress = _logger.progress('Fetching pubspec.lock...'); 358 | final directLockPackages = await _getDirectLockPackages(); 359 | stopProgress(); 360 | 361 | stopProgress = _logger.progress('Fetching pubspec.yaml...'); 362 | final allPubspecDependencies = await _getAllPubspecDependencies(); 363 | stopProgress(); 364 | 365 | final useCaretSyntax = 366 | _args.caretSyntaxPreference == CaretSyntaxPreference.auto 367 | ? await _getCaretUsageTrend(allPubspecDependencies) 368 | : _args.caretSyntaxPreference == CaretSyntaxPreference.always; 369 | 370 | if (useCaretSyntax) { 371 | _logger.info('Using caret syntax.'); 372 | } else { 373 | _logger.info('Not using caret syntax.'); 374 | } 375 | 376 | stopProgress = _logger.progress('Queueing changes...'); 377 | final allChanges = []; 378 | for (final type in _args.dependencyTypes) { 379 | final dependenciesForType = allPubspecDependencies.where( 380 | (dep) => dep.type == type, 381 | ); 382 | for (final dependency in dependenciesForType) { 383 | final package = directLockPackages.firstWhere( 384 | (dep) => dep.name == dependency.name, 385 | ); 386 | allChanges.add( 387 | DependencyChange( 388 | name: dependency.name, 389 | originalVersion: dependency.version, 390 | newVersion: package.version, 391 | type: type, 392 | ), 393 | ); 394 | } 395 | } 396 | stopProgress(); 397 | 398 | _logger 399 | ..info('') 400 | ..info(styleBold.wrap('Queued changes')); 401 | for (final type in _args.dependencyTypes) { 402 | _logger.info('${type.getPubspecName()}:'); 403 | 404 | final changesForType = allChanges.where((change) => change.type == type); 405 | for (final change in changesForType) { 406 | final originalVersionString = change.originalVersion.orIfEmpty( 407 | styleItalic.wrap('empty')!, 408 | ); 409 | 410 | if (!change.hasChange) { 411 | _logger.info( 412 | lightGray.wrap(' ${change.name} ($originalVersionString)'), 413 | ); 414 | } else { 415 | final icon = change.originalVersion.isEmpty 416 | ? green.wrap('+') 417 | : lightGreen.wrap('↑'); 418 | final newVersionString = styleBold.wrap( 419 | '${useCaretSyntax ? '^' : ''}${change.newVersion}', 420 | ); 421 | 422 | _logger.info( 423 | '$icon ${change.name} ' 424 | '($originalVersionString -> ' 425 | '$newVersionString)', 426 | ); 427 | } 428 | } 429 | } 430 | 431 | _logger.info(''); 432 | 433 | final applicableChanges = allChanges.where((change) => change.hasChange); 434 | 435 | if (_args.dryRun) { 436 | if (applicableChanges.isNotEmpty) { 437 | _logger.warn('Dry-run mode: some changes would be made.'); 438 | return const SyncResult(didMakeChanges: true); 439 | } else { 440 | _logger.alert('Dry-run mode: no changes would be made.'); 441 | return const SyncResult(didMakeChanges: false); 442 | } 443 | } 444 | 445 | if (applicableChanges.isEmpty) { 446 | _logger.alert('No changes to apply.'); 447 | return const SyncResult(didMakeChanges: false); 448 | } 449 | 450 | stopProgress = _logger.progress('Preparing changes...'); 451 | var pubspecContents = await _pubspecYamlFile.readAsString(); 452 | for (final type in _args.dependencyTypes) { 453 | final changesForType = applicableChanges.where( 454 | (change) => change.type == type, 455 | ); 456 | for (final change in changesForType) { 457 | pubspecContents = await _applyChangeToContent( 458 | contents: pubspecContents, 459 | change: change, 460 | useCaretSyntax: useCaretSyntax, 461 | ); 462 | } 463 | } 464 | stopProgress(); 465 | 466 | stopProgress = _logger.progress('Applying changes...'); 467 | await _pubspecYamlFile.writeAsString(pubspecContents); 468 | stopProgress(); 469 | 470 | return const SyncResult(didMakeChanges: true); 471 | } 472 | } 473 | --------------------------------------------------------------------------------