From 5ba5a028dcce3264fd2c83330e524bfe2a32d6ea Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 13:47:26 -0600 Subject: [PATCH 01/26] Add pubspec mod CLI (pm) --- .github/dependabot.yml | 11 + .github/workflows/ci.yml | 32 + Makefile | 39 + README.md | 137 +++- analysis_options.yaml | 3 + bin/pm.dart | 664 ++++++++++++++++++ pubspec.yaml | 13 +- test/fixtures/pubmod/basic/pubspec.yaml | 10 + .../recursive/packages/bad/pubspec.yaml | 5 + .../recursive/packages/child/pubspec.yaml | 6 + .../packages/double_quoted/pubspec.yaml | 6 + .../recursive/packages/hosted/pubspec.yaml | 8 + test/fixtures/pubmod/recursive/pubspec.yaml | 6 + test/fixtures/pubmod/sdk_basic/pubspec.yaml | 6 + .../sdk_recursive/packages/bad/pubspec.yaml | 5 + .../sdk_recursive/packages/child/pubspec.yaml | 6 + .../packages/double_quoted/pubspec.yaml | 6 + .../packages/no_env/pubspec.yaml | 4 + .../pubmod/sdk_recursive/pubspec.yaml | 6 + test/pm_test.dart | 278 ++++++++ 20 files changed, 1243 insertions(+), 8 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 analysis_options.yaml create mode 100644 bin/pm.dart create mode 100644 test/fixtures/pubmod/basic/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/child/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml create mode 100644 test/fixtures/pubmod/recursive/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_basic/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml create mode 100644 test/fixtures/pubmod/sdk_recursive/pubspec.yaml create mode 100644 test/pm_test.dart diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9a5430a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efdef0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Dart SDK + uses: dart-lang/setup-dart@v1 + + - name: Install dependencies + run: dart pub get + + - name: Analyze + run: dart analyze . + + - name: Check formatting + run: dart format -o none --set-exit-if-changed . + + - name: Run tests + run: dart test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b592e31 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# This makefile leverages automatic documentation. Running `make` will generate a list +# of the most commonly used targets. `make help` will generate a more complete list. +# +# When adding a target, prefix the doc string with two pound signs to add it to the common +# list or two pounds and VERBOSE to demote it to the `make help` list. Prefix it with two pounds +# and INTERNAL to exclude from all help, or just don't add a doc string. +# +# Based on https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html + +.DEFAULT_GOAL := help +SHELL=/bin/bash -o pipefail + +# Supports: +# make install /usr/local/bin +# make install +INSTALLPATH ?= $(if $(word 2,$(MAKECMDGOALS)),$(word 2,$(MAKECMDGOALS)),$(HOME)/bin) + +ifneq (,$(filter install,$(MAKECMDGOALS))) +ifneq ($(word 2,$(MAKECMDGOALS)),) +.PHONY: $(word 2,$(MAKECMDGOALS)) +$(word 2,$(MAKECMDGOALS)): + @: +endif +endif + +.PHONY: help +help: ##META Display all make targets + @printf "\33[32m" + @echo "All documented make targets. Use 'make' to see only the most commonly used targets." + @printf "\033[0m\n" + + @grep -E '^%?[a-zA-Z0-9/_-]+:.*?##(VERBOSE)? .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##(VERBOSE)? "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +# Compile both executables in bin +.PHONY: install +install: ## Build and install executables (default: ~/bin) + @mkdir -p "$(INSTALLPATH)" + dart compile exe bin/replace.dart -o "$(INSTALLPATH)/replace" + dart compile exe bin/pm.dart -o "$(INSTALLPATH)/pm" \ No newline at end of file diff --git a/README.md b/README.md index e1b8294..eb8bd24 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,18 @@ allow it or work around it by renaming the file, replacing, and renaming it back ### Installing `pub global activate replace` +This installs both command line tools from this package: +- `replace` for regex find/replace in files +- `pm` for editing dependency constraints in pubspec.yaml files + or more advanced install - clone the project - `pub get` - `dart compile exe bin/replace.dart -o replace` -- Place the `replace` executable in your path +- `dart compile exe bin/pm.dart -o pm` +- Place the `replace` and `pm` executable in your path -### Running / Usage +### How to use replace `replace ...` This means you can pass as many globs, directory names, or filenames @@ -62,3 +67,131 @@ Match a word at the beginning of a line Match a word at the end of a line `replace "dessert$" cookies menu.txt` + +## How to use pm + +`pm` updates dependency version constraints in one or more pubspec.yaml files. + +### Usage + +`pm [global options] ` + +Global options: +- `-h, --help` Print usage information +- `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files +- `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed + +Commands: +- `set` Set dependency constraint exactly as provided +- `set-sdk` Set `environment.sdk` constraint exactly as provided +- `raise-min` Raise the minimum bound (inclusive) of a version range +- `raise-min-sdk` Raise the minimum `environment.sdk` bound (inclusive) +- `raise-max` Raise the maximum bound (exclusive) of a version range +- `raise-max-sdk` Raise the maximum `environment.sdk` bound (exclusive) +- `lower-max` Lower the maximum bound (exclusive) of a version range + +Notes: +- Updates both `dependencies` and `dev_dependencies` +- SDK commands update `environment.sdk` only +- `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` +- `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` +- `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` +- `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` + +### Examples + +Set a dependency version: + +```sh +pm set path 1.9.1 +``` + +Set a single version to a range constraint: + +```sh +pm set path '>=1.9.0 <2.0.0' +``` + +Before: + +```yaml +dependencies: + path: 1.9.1 +``` + +After: + +```yaml +dependencies: + path: '>=1.9.0 <2.0.0' +``` + +Raise minimum version recursively across a monorepo: + +```sh +pm raise-min path 1.9.1 -r +``` + +Range to single version when the new minimum meets or exceeds the old maximum: + +```sh +pm raise-min path 1.9.1 +``` + +Before: + +```yaml +dependencies: + path: '>=1.8.0 <1.9.1' +``` + +After: + +```yaml +dependencies: + path: 1.9.1 +``` + +Range to narrower range when the upper bound is still greater than the new minimum: + +```sh +pm raise-min path 1.9.1 +``` + +Before: + +```yaml +dependencies: + path: '>=1.8.0 <2.0.0' +``` + +After: + +```yaml +dependencies: + path: '>=1.9.1 <2.0.0' +``` + +Lower max version and fail if any pubspec is malformed: + +```sh +pm lower-max path 2.5.0 -r --fail-on-parse-error +``` + +Set SDK constraint: + +```sh +pm set-sdk '>=3.3.0 <4.0.0' +``` + +Raise SDK minimum recursively across a monorepo: + +```sh +pm raise-min-sdk 3.4.0 -r +``` + +Raise SDK maximum recursively: + +```sh +pm raise-max-sdk 5.0.0 -r +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..c065a24 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + exclude: + - test/fixtures/** \ No newline at end of file diff --git a/bin/pm.dart b/bin/pm.dart new file mode 100644 index 0000000..059ad54 --- /dev/null +++ b/bin/pm.dart @@ -0,0 +1,664 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; +import 'package:pubspec_manager/pubspec_manager.dart' + hide Version, VersionConstraint; + +const _commandLowerMax = 'lower-max'; +const _commandRaiseMax = 'raise-max'; +const _commandRaiseMaxSdk = 'raise-max-sdk'; +const _commandRaiseMin = 'raise-min'; +const _commandRaiseMinSdk = 'raise-min-sdk'; +const _commandSet = 'set'; +const _commandSetSdk = 'set-sdk'; + +const _usageHeader = 'Usage: dart run pubmod [arguments]'; + +Future main(List args) async { + final exitCode = await _run(args); + if (exitCode != 0) { + // ignore: avoid_print + stderr.writeln('pubmod failed with exit code $exitCode.'); + } + exit(exitCode); +} + +Future _run(List args) async { + final parser = _buildParser(); + + late ArgResults results; + try { + results = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln(e.message); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final showHelp = results['help'] as bool; + if (showHelp) { + _printUsage(parser); + return 0; + } + + final command = results.command; + if (command == null) { + _printUsage(parser); + return 64; + } + + final commandName = command.name; + if (commandName == null) { + _printUsage(parser); + return 64; + } + final targetsSdk = _isSdkCommand(commandName); + final rest = command.rest; + late String dependencyName; + late String versionText; + if (targetsSdk) { + if (rest.length != 1) { + stderr.writeln( + 'Command "$commandName" requires exactly 1 argument: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + dependencyName = 'sdk'; + versionText = rest[0].trim(); + if (versionText.isEmpty) { + stderr.writeln('Version must not be empty.'); + return 64; + } + } else { + if (rest.length != 2) { + stderr.writeln( + 'Command "$commandName" requires exactly 2 arguments: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + dependencyName = rest[0].trim(); + versionText = rest[1].trim(); + if (dependencyName.isEmpty || versionText.isEmpty) { + stderr.writeln('Dependency and version must not be empty.'); + return 64; + } + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + + final op = _Operation.fromCommand(commandName); + if (op == null) { + stderr.writeln('Unknown command: $commandName'); + return 64; + } + + if (op == _Operation.set) { + try { + semver.VersionConstraint.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); + return 64; + } + } else { + try { + semver.Version.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version "$versionText": ${e.message}'); + return 64; + } + } + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + + final updateMessages = []; + if (targetsSdk) { + updateMessages.addAll(_applyToSdk(pubspec, op, versionText)); + } else { + updateMessages.addAll( + _applyToDependencies(pubspec.dependencies, dependencyName, op, versionText), + ); + updateMessages.addAll( + _applyToDependencies( + pubspec.devDependencies, + dependencyName, + op, + versionText, + ), + ); + } + + final changed = updateMessages.isNotEmpty; + + if (!changed) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; +} + +List _applyToDependencies( + Dependencies dependencies, + String dependencyName, + _Operation operation, + String inputVersion, +) { + final dependency = dependencies[dependencyName]; + if (dependency == null) { + return const []; + } + + if (dependency is! DependencyVersioned) { + return const []; + } + + final versionedDependency = dependency as DependencyVersioned; + final currentText = versionedDependency.versionConstraint.trim(); + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString()})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; +} + +List _applyToSdk( + PubSpec pubspec, + _Operation operation, + String inputVersion, +) { + final currentText = pubspec.environment.sdk.trim(); + if (currentText.isEmpty) { + return const []; + } + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping sdk: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString()})', + ); + return const []; + } + + final nextText = _toYamlConstraint(nextBounds.toConstraintString(), currentText); + if (nextText == currentText) { + return const []; + } + + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; +} + +ArgParser _buildParser() { + final parser = ArgParser() + ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag( + 'fail-on-parse-error', + negatable: false, + help: 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', + ) + ..addFlag( + 'recursive', + abbr: 'r', + negatable: false, + help: 'Recurse through subdirectories and run on all pubspec.yaml files', + ); + + for (final command in [ + _commandLowerMax, + _commandRaiseMax, + _commandRaiseMaxSdk, + _commandRaiseMin, + _commandRaiseMinSdk, + _commandSet, + _commandSetSdk, + ]) { + parser.addCommand(command); + } + + return parser; +} + +void _printUsage(ArgParser parser) { + stdout.writeln(_usageHeader); + stdout.writeln(''); + stdout.writeln('Global options:'); + stdout.writeln(parser.usage); + stdout.writeln('Available commands:'); + stdout.writeln('(dependencies)'); + stdout.writeln(' set Set the version of a dependency'); + stdout.writeln(' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln(' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln(' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); + stdout.writeln('(sdk)'); + stdout.writeln(' set-sdk Set the SDK version constraint in environment.sdk.'); + stdout.writeln(' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); + stdout.writeln(' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); +} + +bool _isSdkCommand(String commandName) { + return commandName == _commandSetSdk || + commandName == _commandRaiseMinSdk || + commandName == _commandRaiseMaxSdk; +} + +List _findPubspecFiles({required bool recursive}) { + final root = Directory.current; + if (!recursive) { + final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); + return file.existsSync() ? [file.absolute.path] : const []; + } + + final paths = {}; + for (final entity in root.listSync(recursive: true, followLinks: false)) { + if (entity is! File) { + continue; + } + + final parts = entity.path.split(Platform.pathSeparator); + if (parts.contains('.git')) { + continue; + } + + if (parts.isEmpty || parts.last != 'pubspec.yaml') { + continue; + } + paths.add(entity.absolute.path); + } + + final result = paths.toList()..sort(); + return result; +} + +bool _hasHigherOrEqualInclusiveMin( + _ConstraintBounds bounds, + semver.Version target, +) { + final min = bounds.min; + if (min == null) { + return false; + } + final cmp = min.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return bounds.includeMin; +} + +bool _hasHigherOrEqualExclusiveMax( + _ConstraintBounds bounds, + semver.Version target, +) { + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return !bounds.includeMax; +} + +bool _hasLowerOrEqualExclusiveMax( + _ConstraintBounds bounds, + semver.Version target, +) { + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return !bounds.includeMax; +} + +enum _Operation { + lowerMax, + raiseMax, + raiseMin, + set; + + static _Operation? fromCommand(String command) { + switch (command) { + case _commandLowerMax: + return _Operation.lowerMax; + case _commandRaiseMax: + case _commandRaiseMaxSdk: + return _Operation.raiseMax; + case _commandRaiseMin: + case _commandRaiseMinSdk: + return _Operation.raiseMin; + case _commandSet: + case _commandSetSdk: + return _Operation.set; + } + return null; + } +} + +class _ConstraintBounds { + _ConstraintBounds({ + this.min, + this.includeMin = true, + this.max, + this.includeMax = false, + }); + + semver.Version? min; + bool includeMin; + semver.Version? max; + bool includeMax; + + static _ConstraintBounds? fromConstraint(semver.VersionConstraint constraint) { + if (constraint == semver.VersionConstraint.any) { + return _ConstraintBounds(min: null, max: null); + } + + if (constraint is semver.Version) { + return _ConstraintBounds( + min: constraint, + includeMin: true, + max: constraint, + includeMax: true, + ); + } + + if (constraint is semver.VersionRange) { + return _ConstraintBounds( + min: constraint.min, + includeMin: constraint.includeMin, + max: constraint.max, + includeMax: constraint.includeMax, + ); + } + + return null; + } + + _ConstraintBounds copy() => _ConstraintBounds( + min: min, + includeMin: includeMin, + max: max, + includeMax: includeMax, + ); + + bool isValid() { + if (min == null || max == null) { + return true; + } + final cmp = min!.compareTo(max!); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return includeMin && includeMax; + } + + String toConstraintString() { + if (min == null && max == null) { + return 'any'; + } + + if (min != null && max != null && min == max && includeMin && includeMax) { + return min.toString(); + } + + final pieces = []; + if (min != null) { + pieces.add('${includeMin ? '>=' : '>'}$min'); + } + if (max != null) { + final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); + pieces.add('${includeMax ? '<=' : '<'}$displayMax'); + } + return pieces.join(' '); + } +} + +semver.Version _normalizeExclusiveMax(semver.Version version) { + if (!version.isPreRelease) { + return version; + } + + final pre = version.preRelease; + if (pre.length == 1 && pre.first == 0) { + return semver.Version(version.major, version.minor, version.patch); + } + + return version; +} + +String _stripMatchingQuotes(String text) { + if (text.length < 2) { + return text; + } + + final first = text[0]; + final last = text[text.length - 1]; + if ((first == '\'' || first == '"') && first == last) { + return text.substring(1, text.length - 1).trim(); + } + return text; +} + +String _toYamlConstraint(String value, String existingValue) { + final trimmed = value.trim(); + final needsQuoting = RegExp(r'\s').hasMatch(trimmed); + if (!needsQuoting) { + return trimmed; + } + + final existingTrimmed = existingValue.trim(); + final useDoubleQuote = + existingTrimmed.length >= 2 && + existingTrimmed.startsWith('"') && + existingTrimmed.endsWith('"'); + + if (useDoubleQuote) { + final escaped = trimmed + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"'); + return '"$escaped"'; + } + + final escaped = trimmed.replaceAll("'", "''"); + return "'$escaped'"; +} + +String _buildUpdateMessage(String name, String before, String after) { + final beforeDisplay = _formatConstraintForMessage(before); + final afterDisplay = _formatConstraintForMessage(after); + return '$name $beforeDisplay updated to $afterDisplay'; +} + +String _formatConstraintForMessage(String value) { + final normalized = _stripMatchingQuotes(value.trim()); + if (_looksLikeRangeConstraint(normalized)) { + final escaped = normalized.replaceAll("'", "''"); + return "'$escaped'"; + } + return normalized; +} + +bool _looksLikeRangeConstraint(String value) { + if (value.contains(' ') || value.contains('||')) { + return true; + } + return value.startsWith('>=') || + value.startsWith('<=') || + value.startsWith('>') || + value.startsWith('<'); +} diff --git a/pubspec.yaml b/pubspec.yaml index 1802e82..bf6bd9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,18 +4,19 @@ description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: args: ^2.0.0 cli_script: ^0.2.3 file: ^6.0.0 glob: ^2.0.0 + pubspec_manager: ^4.0.1 + +dev_dependencies: + path: ^1.9.0 + test: ^1.25.0 executables: replace: - -cider: - link_template: - tag: https://github.com/robrbecker/replace/releases/tag/%tag% - diff: https://github.com/robrbecker/replace/compare/%from%...%to% \ No newline at end of file + pubmod: diff --git a/test/fixtures/pubmod/basic/pubspec.yaml b/test/fixtures/pubmod/basic/pubspec.yaml new file mode 100644 index 0000000..3231e53 --- /dev/null +++ b/test/fixtures/pubmod/basic/pubspec.yaml @@ -0,0 +1,10 @@ +name: basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 +dev_dependencies: + path: + hosted: https://pub.dev + version: ^1.9.0 diff --git a/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml new file mode 100644 index 0000000..9e2d38d --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml @@ -0,0 +1,5 @@ +name: malformed_fixture +version: 0.0.1 +dependencies: + foo: ^1.0.0 + foo: ^2.0.0 diff --git a/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml new file mode 100644 index 0000000..5e50854 --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_child_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=1.8.0 <2.0.0' diff --git a/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml new file mode 100644 index 0000000..f75e13c --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_double_quoted_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ">=1.7.0 <2.0.0" diff --git a/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml new file mode 100644 index 0000000..a6f8ef5 --- /dev/null +++ b/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml @@ -0,0 +1,8 @@ +name: recursive_hosted_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: + hosted: https://pub.dev + version: ^1.8.0 diff --git a/test/fixtures/pubmod/recursive/pubspec.yaml b/test/fixtures/pubmod/recursive/pubspec.yaml new file mode 100644 index 0000000..5a39e4a --- /dev/null +++ b/test/fixtures/pubmod/recursive/pubspec.yaml @@ -0,0 +1,6 @@ +name: recursive_root_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_basic/pubspec.yaml b/test/fixtures/pubmod/sdk_basic/pubspec.yaml new file mode 100644 index 0000000..cc189c0 --- /dev/null +++ b/test/fixtures/pubmod/sdk_basic/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml new file mode 100644 index 0000000..882c441 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml @@ -0,0 +1,5 @@ +name: sdk_recursive_bad_fixture +version: 0.0.1 +dependencies: + foo: ^1.0.0 + foo: ^2.0.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml new file mode 100644 index 0000000..9d301a6 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_child_fixture +version: 0.0.1 +environment: + sdk: '>=3.1.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml new file mode 100644 index 0000000..fe92ee6 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_double_quoted_fixture +version: 0.0.1 +environment: + sdk: ">=3.2.0 <4.0.0" +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml new file mode 100644 index 0000000..409ea16 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml @@ -0,0 +1,4 @@ +name: sdk_recursive_no_env_fixture +version: 0.0.1 +dependencies: + path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/pubspec.yaml new file mode 100644 index 0000000..ad29209 --- /dev/null +++ b/test/fixtures/pubmod/sdk_recursive/pubspec.yaml @@ -0,0 +1,6 @@ +name: sdk_recursive_root_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 diff --git a/test/pm_test.dart b/test/pm_test.dart new file mode 100644 index 0000000..b128abd --- /dev/null +++ b/test/pm_test.dart @@ -0,0 +1,278 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late Directory scratchRoot; + + setUp(() { + scratchRoot = Directory.systemTemp.createTempSync('pubmod_test_'); + }); + + tearDown(() { + if (scratchRoot.existsSync()) { + scratchRoot.deleteSync(recursive: true); + } + }); + + group('pubmod CLI', () { + test('set-sdk updates environment sdk constraint in current pubspec', () async { + final workDir = _copyFixture('sdk_basic', scratchRoot); + + final result = await _runPubmod( + ['set-sdk', '>=3.3.0 <4.0.0'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect( + result.stdout.toString(), + contains("sdk '>=3.0.0 <4.0.0' updated to '>=3.3.0 <4.0.0'"), + ); + + final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasSdkConstraint(content, '>=3.3.0 <4.0.0'), isTrue); + }); + + test('raise-min-sdk updates minimum sdk recursively', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final noEnv = + File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); + expect(_hasSdkConstraint(child, '>=3.4.0 <4.0.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '>=3.4.0 <4.0.0'), isTrue); + expect(noEnv, isNot(contains('environment:'))); + }); + + test('raise-max-sdk updates upper sdk bound recursively', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-max-sdk', '5.0.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + + expect(_hasSdkConstraint(root, '>=3.0.0 <5.0.0'), isTrue); + expect(_hasSdkConstraint(child, '>=3.1.0 <5.0.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '>=3.2.0 <5.0.0'), isTrue); + }); + + test('sdk commands respect fail-on-parse-error', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '--fail-on-parse-error', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 1); + expect(result.stderr.toString(), contains('Unable to parse')); + }); + + test('set updates plain and hosted dependency styles', () async { + final workDir = _copyFixture('basic', scratchRoot); + + final result = await _runPubmod( + ['set', 'path', '1.9.1'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); + + final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(content, contains('path: 1.9.1')); + expect(content, contains('version: 1.9.1')); + }); + + test('raise-min updates recursive plain constraints including quoted ranges', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect( + result.stdout.toString(), + contains("path '>=1.8.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), + ); + expect( + result.stdout.toString(), + contains("path '>=1.7.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), + ); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(child, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(_hasConstraint(hosted, 'version', '>=1.9.1 <2.0.0'), isTrue); + }); + + test('raise-max and lower-max update exclusive upper bound only when needed', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final raiseResult = await _runPubmod( + ['raise-max', 'path', '3.0.0', '-r'], + workingDirectory: workDir.path, + ); + expect(raiseResult.exitCode, 0, reason: raiseResult.stderr.toString()); + + final loweredResult = await _runPubmod( + ['lower-max', 'path', '2.5.0', '-r'], + workingDirectory: workDir.path, + ); + expect(loweredResult.exitCode, 0, reason: loweredResult.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(raiseResult.stdout.toString()); + _expectUpdateOutput(loweredResult.stdout.toString()); + expect( + loweredResult.stdout.toString(), + contains("path '>=1.8.0 <3.0.0' updated to '>=1.8.0 <2.5.0'"), + ); + + final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); + expect(_hasConstraint(child, 'path', '>=1.8.0 <2.5.0'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '>=1.7.0 <2.5.0'), isTrue); + expect(_hasConstraint(hosted, 'version', '>=1.8.0 <2.5.0'), isTrue); + }); + + test('fail-on-parse-error returns non-zero on malformed pubspec', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['set', 'path', '1.9.1', '--fail-on-parse-error', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 1); + expect(result.stderr.toString(), contains('Unable to parse')); + }); + }); +} + +Future _assertPubGetParses(String workingDirectory) async { + final result = await Process.run('dart', ['pub', 'get'], workingDirectory: workingDirectory); + expect( + result.exitCode, + 0, + reason: 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', + ); +} + +Directory _copyFixture(String fixtureName, Directory parent) { + final source = Directory( + p.join(_repoRoot.path, 'test', 'fixtures', 'pubmod', fixtureName), + ); + if (!source.existsSync()) { + throw StateError('Fixture not found: ${source.path}'); + } + + final target = Directory(p.join(parent.path, fixtureName))..createSync(recursive: true); + _copyDirectory(source, target); + return target; +} + +void _copyDirectory(Directory source, Directory target) { + for (final entity in source.listSync(recursive: true, followLinks: false)) { + final relative = p.relative(entity.path, from: source.path); + final destinationPath = p.join(target.path, relative); + + if (entity is Directory) { + Directory(destinationPath).createSync(recursive: true); + } else if (entity is File) { + File(destinationPath) + ..createSync(recursive: true) + ..writeAsBytesSync(entity.readAsBytesSync()); + } + } +} + +Future _runPubmod( + List args, { + required String workingDirectory, +}) { + final packageConfig = p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); + final script = p.join(_repoRoot.path, 'bin', 'pm.dart'); + + return Process.run( + 'dart', + ['--packages=$packageConfig', script, ...args], + workingDirectory: workingDirectory, + ); +} + +Directory get _repoRoot => Directory.current; + +bool _hasConstraint(String content, String key, String constraint) { + return content.contains("$key: $constraint") || + content.contains("$key: '$constraint'") || + content.contains('$key: "$constraint"'); +} + +bool _hasSdkConstraint(String content, String constraint) { + return content.contains('sdk: $constraint') || + content.contains("sdk: '$constraint'") || + content.contains('sdk: "$constraint"'); +} + +void _expectUpdateOutput(String stdout) { + expect( + stdout, + matches(RegExp(r'^[^\s]+ .+ updated to .+$', multiLine: true)), + reason: 'Expected update output lines in format: updated to ', + ); +} From c3ff1a2ed1fff47f68fded7ed06e0173d7f1d75a Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:06:01 -0600 Subject: [PATCH 02/26] Add --[no-]tighten --- README.md | 41 +- bin/pm.dart | 1233 ++++++++++++++++++++++++--------------------- test/pm_test.dart | 215 +++++++- 3 files changed, 870 insertions(+), 619 deletions(-) diff --git a/README.md b/README.md index eb8bd24..7f117f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # replace + Easy to use cross-platform regex replace command line util. Can't remember the arguments to the `find` command? or how `xargs` works? Maybe `sed` is a little different on your Mac than in Linux? @@ -10,10 +11,11 @@ It's meant to be used in a directory under source control so you can see what files have been changed using a diff. It ignores dotfiles (especially the .git directory) except these - - .gitignore - - .pubignore - - .travis.yml - - .travis.yaml + +- .gitignore +- .pubignore +- .travis.yml +- .travis.yaml Globs don't seem to go into dotfile directories by default so if you want to do that, just include another glob in the command for those (Ex: `replace fish zebra .github/**.md **/*.md`) @@ -21,21 +23,25 @@ include another glob in the command for those (Ex: `replace fish zebra .github/* If you need to replace in dotfiles other than these, you can submit a PR to this repo to allow it or work around it by renaming the file, replacing, and renaming it back. -### Installing +## Installing + `pub global activate replace` This installs both command line tools from this package: + - `replace` for regex find/replace in files - `pm` for editing dependency constraints in pubspec.yaml files or more advanced install + - clone the project - `pub get` - `dart compile exe bin/replace.dart -o replace` - `dart compile exe bin/pm.dart -o pm` - Place the `replace` and `pm` executable in your path -### How to use replace +## How to use replace + `replace ...` This means you can pass as many globs, directory names, or filenames @@ -50,7 +56,8 @@ replacement, you can use quotes and `noglob`. Example: `noglob replace "key & peele" "ren || stimpy" **/*.md` Regexes and globs are Dart style -Glob Syntax: https://pub.dev/packages/glob#syntax + +Glob Syntax: [https://pub.dev/packages/glob#syntax](https://pub.dev/packages/glob#syntax) The replacement may contain references to the capture groups in regexp using a backslash followed by the group number. Backslashes not followed by a number return the character immediately following them. @@ -77,11 +84,14 @@ Match a word at the end of a line `pm [global options] ` Global options: + - `-h, --help` Print usage information - `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files - `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed +- `--[no-]tighten` Tighten is enabled by default. Use `--no-tighten` to keep explicit range output. Commands: + - `set` Set dependency constraint exactly as provided - `set-sdk` Set `environment.sdk` constraint exactly as provided - `raise-min` Raise the minimum bound (inclusive) of a version range @@ -91,12 +101,17 @@ Commands: - `lower-max` Lower the maximum bound (exclusive) of a version range Notes: + - Updates both `dependencies` and `dev_dependencies` - SDK commands update `environment.sdk` only - `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` - `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` - `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` - `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` +- Tightening is enabled by default and only rewrites when the updated range is exactly equivalent to a caret constraint + - `'>=3.0.0 <4.0.0'` becomes `^3.0.0` + - `'>=0.18.2 <0.19.0'` becomes `^0.18.2` + - `'>=1.2.3 <4.0.0'` stays as a range (not equivalent to a caret constraint) ### Examples @@ -132,6 +147,12 @@ Raise minimum version recursively across a monorepo: pm raise-min path 1.9.1 -r ``` +Raise minimum version recursively and opt out of tightening: + +```sh +pm raise-min path 1.9.1 -r --no-tighten +``` + Range to single version when the new minimum meets or exceeds the old maximum: ```sh @@ -190,6 +211,12 @@ Raise SDK minimum recursively across a monorepo: pm raise-min-sdk 3.4.0 -r ``` +Raise SDK minimum recursively and opt out of tightening: + +```sh +pm raise-min-sdk 3.4.0 -r --no-tighten +``` + Raise SDK maximum recursively: ```sh diff --git a/bin/pm.dart b/bin/pm.dart index 059ad54..55ee57a 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:pub_semver/pub_semver.dart' as semver; import 'package:pubspec_manager/pubspec_manager.dart' - hide Version, VersionConstraint; + hide Version, VersionConstraint; const _commandLowerMax = 'lower-max'; const _commandRaiseMax = 'raise-max'; @@ -16,649 +16,714 @@ const _commandSetSdk = 'set-sdk'; const _usageHeader = 'Usage: dart run pubmod [arguments]'; Future main(List args) async { - final exitCode = await _run(args); - if (exitCode != 0) { - // ignore: avoid_print - stderr.writeln('pubmod failed with exit code $exitCode.'); - } - exit(exitCode); + final exitCode = await _run(args); + if (exitCode != 0) { + // ignore: avoid_print + stderr.writeln('pubmod failed with exit code $exitCode.'); + } + exit(exitCode); } Future _run(List args) async { - final parser = _buildParser(); - - late ArgResults results; - try { - results = parser.parse(args); - } on FormatException catch (e) { - stderr.writeln(e.message); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - - final showHelp = results['help'] as bool; - if (showHelp) { - _printUsage(parser); - return 0; - } - - final command = results.command; - if (command == null) { - _printUsage(parser); - return 64; - } - - final commandName = command.name; - if (commandName == null) { - _printUsage(parser); - return 64; - } - final targetsSdk = _isSdkCommand(commandName); - final rest = command.rest; - late String dependencyName; - late String versionText; - if (targetsSdk) { - if (rest.length != 1) { - stderr.writeln( - 'Command "$commandName" requires exactly 1 argument: ', - ); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - dependencyName = 'sdk'; - versionText = rest[0].trim(); - if (versionText.isEmpty) { - stderr.writeln('Version must not be empty.'); - return 64; - } - } else { - if (rest.length != 2) { - stderr.writeln( - 'Command "$commandName" requires exactly 2 arguments: ', - ); - stderr.writeln(''); - _printUsage(parser); - return 64; - } - - dependencyName = rest[0].trim(); - versionText = rest[1].trim(); - if (dependencyName.isEmpty || versionText.isEmpty) { - stderr.writeln('Dependency and version must not be empty.'); - return 64; - } - } - - final failOnParseError = results['fail-on-parse-error'] as bool; - final recursive = results['recursive'] as bool; - - final op = _Operation.fromCommand(commandName); - if (op == null) { - stderr.writeln('Unknown command: $commandName'); - return 64; - } - - if (op == _Operation.set) { - try { - semver.VersionConstraint.parse(versionText); - } on FormatException catch (e) { - stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); - return 64; - } - } else { - try { - semver.Version.parse(versionText); - } on FormatException catch (e) { - stderr.writeln('Invalid version "$versionText": ${e.message}'); - return 64; - } - } - - final pubspecFiles = _findPubspecFiles(recursive: recursive); - if (pubspecFiles.isEmpty) { - return 0; - } - - var hadParseError = false; - for (final path in pubspecFiles) { - PubSpec pubspec; - try { - pubspec = PubSpec.loadFromPath(path); - } catch (e) { - hadParseError = true; - stderr.writeln('Unable to parse $path: $e'); - if (failOnParseError) { - return 1; - } - continue; - } - - final before = File(path).readAsStringSync(); - - final updateMessages = []; - if (targetsSdk) { - updateMessages.addAll(_applyToSdk(pubspec, op, versionText)); - } else { - updateMessages.addAll( - _applyToDependencies(pubspec.dependencies, dependencyName, op, versionText), - ); - updateMessages.addAll( - _applyToDependencies( - pubspec.devDependencies, - dependencyName, - op, - versionText, - ), - ); - } - - final changed = updateMessages.isNotEmpty; - - if (!changed) { - continue; - } - - final after = pubspec.toString(); - if (before == after) { - continue; - } - - pubspec.saveTo(path); - for (final message in updateMessages) { - stdout.writeln(message); - } - } - - if (failOnParseError && hadParseError) { - return 1; - } - - return 0; + final parser = _buildParser(); + + late ArgResults results; + try { + results = parser.parse(args); + } on FormatException catch (e) { + stderr.writeln(e.message); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final showHelp = results['help'] as bool; + if (showHelp) { + _printUsage(parser); + return 0; + } + + final command = results.command; + if (command == null) { + _printUsage(parser); + return 64; + } + + final commandName = command.name; + if (commandName == null) { + _printUsage(parser); + return 64; + } + final targetsSdk = _isSdkCommand(commandName); + final rest = command.rest; + late String dependencyName; + late String versionText; + if (targetsSdk) { + if (rest.length != 1) { + stderr.writeln( + 'Command "$commandName" requires exactly 1 argument: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + dependencyName = 'sdk'; + versionText = rest[0].trim(); + if (versionText.isEmpty) { + stderr.writeln('Version must not be empty.'); + return 64; + } + } else { + if (rest.length != 2) { + stderr.writeln( + 'Command "$commandName" requires exactly 2 arguments: ', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + dependencyName = rest[0].trim(); + versionText = rest[1].trim(); + if (dependencyName.isEmpty || versionText.isEmpty) { + stderr.writeln('Dependency and version must not be empty.'); + return 64; + } + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + final tighten = results['tighten'] as bool; + + final op = _Operation.fromCommand(commandName); + if (op == null) { + stderr.writeln('Unknown command: $commandName'); + return 64; + } + + if (op == _Operation.set) { + try { + semver.VersionConstraint.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version constraint "$versionText": ${e.message}'); + return 64; + } + } else { + try { + semver.Version.parse(versionText); + } on FormatException catch (e) { + stderr.writeln('Invalid version "$versionText": ${e.message}'); + return 64; + } + } + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + + final updateMessages = []; + if (targetsSdk) { + updateMessages + .addAll(_applyToSdk(pubspec, op, versionText, tighten: tighten)); + } else { + updateMessages.addAll( + _applyToDependencies( + pubspec.dependencies, + dependencyName, + op, + versionText, + tighten: tighten, + ), + ); + updateMessages.addAll( + _applyToDependencies( + pubspec.devDependencies, + dependencyName, + op, + versionText, + tighten: tighten, + ), + ); + } + + final changed = updateMessages.isNotEmpty; + + if (!changed) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; } List _applyToDependencies( - Dependencies dependencies, - String dependencyName, - _Operation operation, - String inputVersion, -) { - final dependency = dependencies[dependencyName]; - if (dependency == null) { - return const []; - } - - if (dependency is! DependencyVersioned) { - return const []; - } - - final versionedDependency = dependency as DependencyVersioned; - final currentText = versionedDependency.versionConstraint.trim(); - - if (operation == _Operation.set) { - final nextText = _toYamlConstraint(inputVersion.trim(), currentText); - if (nextText == currentText) { - return const []; - } - versionedDependency.versionConstraint = nextText; - return [_buildUpdateMessage(dependency.name, currentText, nextText)]; - } - - final target = semver.Version.parse(inputVersion); - final normalizedCurrent = _stripMatchingQuotes(currentText); - - semver.VersionConstraint currentConstraint; - try { - currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); - } on FormatException catch (e) { - stderr.writeln( - 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', - ); - return const []; - } - final bounds = _ConstraintBounds.fromConstraint(currentConstraint); - if (bounds == null) { - stderr.writeln( - 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', - ); - return const []; - } - - final nextBounds = bounds.copy(); - switch (operation) { - case _Operation.lowerMax: - if (_hasLowerOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMax: - if (_hasHigherOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMin: - if (_hasHigherOrEqualInclusiveMin(bounds, target)) { - return const []; - } - nextBounds.min = target; - nextBounds.includeMin = true; - case _Operation.set: - throw StateError('Unexpected operation in bounds transform.'); - } - - if (!nextBounds.isValid()) { - stderr.writeln( - 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString()})', - ); - return const []; - } - - final nextText = _toYamlConstraint( - nextBounds.toConstraintString(), - currentText, - ); - if (nextText == currentText) { - return const []; - } - - versionedDependency.versionConstraint = nextText; - return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + Dependencies dependencies, + String dependencyName, + _Operation operation, + String inputVersion, { + required bool tighten, +}) { + final dependency = dependencies[dependencyName]; + if (dependency == null) { + return const []; + } + + if (dependency is! DependencyVersioned) { + return const []; + } + + final versionedDependency = dependency as DependencyVersioned; + final currentText = versionedDependency.versionConstraint.trim(); + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping ${dependency.name}: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping ${dependency.name}: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping ${dependency.name}: resulting range would be empty (${nextBounds.toConstraintString(tighten: false)})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(tighten: tighten), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + versionedDependency.versionConstraint = nextText; + return [_buildUpdateMessage(dependency.name, currentText, nextText)]; } List _applyToSdk( - PubSpec pubspec, - _Operation operation, - String inputVersion, -) { - final currentText = pubspec.environment.sdk.trim(); - if (currentText.isEmpty) { - return const []; - } - - if (operation == _Operation.set) { - final nextText = _toYamlConstraint(inputVersion.trim(), currentText); - if (nextText == currentText) { - return const []; - } - pubspec.environment.sdk = nextText; - return [_buildUpdateMessage('sdk', currentText, nextText)]; - } - - final target = semver.Version.parse(inputVersion); - final normalizedCurrent = _stripMatchingQuotes(currentText); - - semver.VersionConstraint currentConstraint; - try { - currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); - } on FormatException catch (e) { - stderr.writeln( - 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', - ); - return const []; - } - - final bounds = _ConstraintBounds.fromConstraint(currentConstraint); - if (bounds == null) { - stderr.writeln( - 'Skipping sdk: unsupported version constraint "$currentText"', - ); - return const []; - } - - final nextBounds = bounds.copy(); - switch (operation) { - case _Operation.lowerMax: - if (_hasLowerOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMax: - if (_hasHigherOrEqualExclusiveMax(bounds, target)) { - return const []; - } - nextBounds.max = target; - nextBounds.includeMax = false; - case _Operation.raiseMin: - if (_hasHigherOrEqualInclusiveMin(bounds, target)) { - return const []; - } - nextBounds.min = target; - nextBounds.includeMin = true; - case _Operation.set: - throw StateError('Unexpected operation in bounds transform.'); - } - - if (!nextBounds.isValid()) { - stderr.writeln( - 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString()})', - ); - return const []; - } - - final nextText = _toYamlConstraint(nextBounds.toConstraintString(), currentText); - if (nextText == currentText) { - return const []; - } - - pubspec.environment.sdk = nextText; - return [_buildUpdateMessage('sdk', currentText, nextText)]; + PubSpec pubspec, + _Operation operation, + String inputVersion, { + required bool tighten, +}) { + final currentText = pubspec.environment.sdk.trim(); + if (currentText.isEmpty) { + return const []; + } + + if (operation == _Operation.set) { + final nextText = _toYamlConstraint(inputVersion.trim(), currentText); + if (nextText == currentText) { + return const []; + } + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; + } + + final target = semver.Version.parse(inputVersion); + final normalizedCurrent = _stripMatchingQuotes(currentText); + + semver.VersionConstraint currentConstraint; + try { + currentConstraint = semver.VersionConstraint.parse(normalizedCurrent); + } on FormatException catch (e) { + stderr.writeln( + 'Skipping sdk: invalid version constraint "$currentText" (${e.message})', + ); + return const []; + } + + final bounds = _ConstraintBounds.fromConstraint(currentConstraint); + if (bounds == null) { + stderr.writeln( + 'Skipping sdk: unsupported version constraint "$currentText"', + ); + return const []; + } + + final nextBounds = bounds.copy(); + switch (operation) { + case _Operation.lowerMax: + if (_hasLowerOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMax: + if (_hasHigherOrEqualExclusiveMax(bounds, target)) { + return const []; + } + nextBounds.max = target; + nextBounds.includeMax = false; + case _Operation.raiseMin: + if (_hasHigherOrEqualInclusiveMin(bounds, target)) { + return const []; + } + nextBounds.min = target; + nextBounds.includeMin = true; + case _Operation.set: + throw StateError('Unexpected operation in bounds transform.'); + } + + if (!nextBounds.isValid()) { + stderr.writeln( + 'Skipping sdk: resulting range would be empty (${nextBounds.toConstraintString(tighten: false)})', + ); + return const []; + } + + final nextText = _toYamlConstraint( + nextBounds.toConstraintString(tighten: tighten), + currentText, + ); + if (nextText == currentText) { + return const []; + } + + pubspec.environment.sdk = nextText; + return [_buildUpdateMessage('sdk', currentText, nextText)]; } ArgParser _buildParser() { - final parser = ArgParser() - ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.') - ..addFlag( - 'fail-on-parse-error', - negatable: false, - help: 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', - ) - ..addFlag( - 'recursive', - abbr: 'r', - negatable: false, - help: 'Recurse through subdirectories and run on all pubspec.yaml files', - ); - - for (final command in [ - _commandLowerMax, - _commandRaiseMax, - _commandRaiseMaxSdk, - _commandRaiseMin, - _commandRaiseMinSdk, - _commandSet, - _commandSetSdk, - ]) { - parser.addCommand(command); - } - - return parser; + final parser = ArgParser() + ..addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag( + 'fail-on-parse-error', + negatable: false, + help: + 'If a pubspec.yaml cannot be parsed, fail immediately and set the exit code', + ) + ..addFlag( + 'recursive', + abbr: 'r', + negatable: false, + help: 'Recurse through subdirectories and run on all pubspec.yaml files', + ) + ..addFlag( + 'tighten', + defaultsTo: true, + help: + 'When updating range constraints, rewrite equivalent ranges as caret constraints (for example >=3.0.0 <4.0.0 to ^3.0.0). Disable with --no-tighten.', + ); + + for (final command in [ + _commandLowerMax, + _commandRaiseMax, + _commandRaiseMaxSdk, + _commandRaiseMin, + _commandRaiseMinSdk, + _commandSet, + _commandSetSdk, + ]) { + parser.addCommand(command); + } + + return parser; } void _printUsage(ArgParser parser) { - stdout.writeln(_usageHeader); - stdout.writeln(''); - stdout.writeln('Global options:'); - stdout.writeln(parser.usage); - stdout.writeln('Available commands:'); + stdout.writeln(_usageHeader); + stdout.writeln(''); + stdout.writeln('Global options:'); + stdout.writeln(parser.usage); + stdout.writeln('Available commands:'); stdout.writeln('(dependencies)'); stdout.writeln(' set Set the version of a dependency'); - stdout.writeln(' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); - stdout.writeln(' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); - stdout.writeln(' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); + stdout.writeln( + ' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln( + ' raise-max Raise the maximum allowed version (exclusive) of a dependency.'); + stdout.writeln( + ' raise-min Raise the minimum allowed version (inclusive) of a dependency.'); stdout.writeln('(sdk)'); - stdout.writeln(' set-sdk Set the SDK version constraint in environment.sdk.'); - stdout.writeln(' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); - stdout.writeln(' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); + stdout.writeln( + ' set-sdk Set the SDK version constraint in environment.sdk.'); + stdout.writeln( + ' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); + stdout.writeln( + ' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); } bool _isSdkCommand(String commandName) { - return commandName == _commandSetSdk || - commandName == _commandRaiseMinSdk || - commandName == _commandRaiseMaxSdk; + return commandName == _commandSetSdk || + commandName == _commandRaiseMinSdk || + commandName == _commandRaiseMaxSdk; } List _findPubspecFiles({required bool recursive}) { - final root = Directory.current; - if (!recursive) { - final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); - return file.existsSync() ? [file.absolute.path] : const []; - } - - final paths = {}; - for (final entity in root.listSync(recursive: true, followLinks: false)) { - if (entity is! File) { - continue; - } - - final parts = entity.path.split(Platform.pathSeparator); - if (parts.contains('.git')) { - continue; - } - - if (parts.isEmpty || parts.last != 'pubspec.yaml') { - continue; - } - paths.add(entity.absolute.path); - } - - final result = paths.toList()..sort(); - return result; + final root = Directory.current; + if (!recursive) { + final file = File('${root.path}${Platform.pathSeparator}pubspec.yaml'); + return file.existsSync() ? [file.absolute.path] : const []; + } + + final paths = {}; + for (final entity in root.listSync(recursive: true, followLinks: false)) { + if (entity is! File) { + continue; + } + + final parts = entity.path.split(Platform.pathSeparator); + if (parts.contains('.git')) { + continue; + } + + if (parts.isEmpty || parts.last != 'pubspec.yaml') { + continue; + } + paths.add(entity.absolute.path); + } + + final result = paths.toList()..sort(); + return result; } bool _hasHigherOrEqualInclusiveMin( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final min = bounds.min; - if (min == null) { - return false; - } - final cmp = min.compareTo(target); - if (cmp > 0) { - return true; - } - if (cmp < 0) { - return false; - } - return bounds.includeMin; + final min = bounds.min; + if (min == null) { + return false; + } + final cmp = min.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return bounds.includeMin; } bool _hasHigherOrEqualExclusiveMax( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final max = bounds.max; - if (max == null) { - return false; - } - final cmp = max.compareTo(target); - if (cmp > 0) { - return true; - } - if (cmp < 0) { - return false; - } - return !bounds.includeMax; + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp > 0) { + return true; + } + if (cmp < 0) { + return false; + } + return !bounds.includeMax; } bool _hasLowerOrEqualExclusiveMax( - _ConstraintBounds bounds, - semver.Version target, + _ConstraintBounds bounds, + semver.Version target, ) { - final max = bounds.max; - if (max == null) { - return false; - } - final cmp = max.compareTo(target); - if (cmp < 0) { - return true; - } - if (cmp > 0) { - return false; - } - return !bounds.includeMax; + final max = bounds.max; + if (max == null) { + return false; + } + final cmp = max.compareTo(target); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return !bounds.includeMax; } enum _Operation { - lowerMax, - raiseMax, - raiseMin, - set; - - static _Operation? fromCommand(String command) { - switch (command) { - case _commandLowerMax: - return _Operation.lowerMax; - case _commandRaiseMax: - case _commandRaiseMaxSdk: - return _Operation.raiseMax; - case _commandRaiseMin: - case _commandRaiseMinSdk: - return _Operation.raiseMin; - case _commandSet: - case _commandSetSdk: - return _Operation.set; - } - return null; - } + lowerMax, + raiseMax, + raiseMin, + set; + + static _Operation? fromCommand(String command) { + switch (command) { + case _commandLowerMax: + return _Operation.lowerMax; + case _commandRaiseMax: + case _commandRaiseMaxSdk: + return _Operation.raiseMax; + case _commandRaiseMin: + case _commandRaiseMinSdk: + return _Operation.raiseMin; + case _commandSet: + case _commandSetSdk: + return _Operation.set; + } + return null; + } } class _ConstraintBounds { - _ConstraintBounds({ - this.min, - this.includeMin = true, - this.max, - this.includeMax = false, - }); - - semver.Version? min; - bool includeMin; - semver.Version? max; - bool includeMax; - - static _ConstraintBounds? fromConstraint(semver.VersionConstraint constraint) { - if (constraint == semver.VersionConstraint.any) { - return _ConstraintBounds(min: null, max: null); - } - - if (constraint is semver.Version) { - return _ConstraintBounds( - min: constraint, - includeMin: true, - max: constraint, - includeMax: true, - ); - } - - if (constraint is semver.VersionRange) { - return _ConstraintBounds( - min: constraint.min, - includeMin: constraint.includeMin, - max: constraint.max, - includeMax: constraint.includeMax, - ); - } - - return null; - } - - _ConstraintBounds copy() => _ConstraintBounds( - min: min, - includeMin: includeMin, - max: max, - includeMax: includeMax, - ); - - bool isValid() { - if (min == null || max == null) { - return true; - } - final cmp = min!.compareTo(max!); - if (cmp < 0) { - return true; - } - if (cmp > 0) { - return false; - } - return includeMin && includeMax; - } - - String toConstraintString() { - if (min == null && max == null) { - return 'any'; - } - - if (min != null && max != null && min == max && includeMin && includeMax) { - return min.toString(); - } - - final pieces = []; - if (min != null) { - pieces.add('${includeMin ? '>=' : '>'}$min'); - } - if (max != null) { - final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); - pieces.add('${includeMax ? '<=' : '<'}$displayMax'); - } - return pieces.join(' '); - } + _ConstraintBounds({ + this.min, + this.includeMin = true, + this.max, + this.includeMax = false, + }); + + semver.Version? min; + bool includeMin; + semver.Version? max; + bool includeMax; + + static _ConstraintBounds? fromConstraint( + semver.VersionConstraint constraint) { + if (constraint == semver.VersionConstraint.any) { + return _ConstraintBounds(min: null, max: null); + } + + if (constraint is semver.Version) { + return _ConstraintBounds( + min: constraint, + includeMin: true, + max: constraint, + includeMax: true, + ); + } + + if (constraint is semver.VersionRange) { + return _ConstraintBounds( + min: constraint.min, + includeMin: constraint.includeMin, + max: constraint.max, + includeMax: constraint.includeMax, + ); + } + + return null; + } + + _ConstraintBounds copy() => _ConstraintBounds( + min: min, + includeMin: includeMin, + max: max, + includeMax: includeMax, + ); + + bool isValid() { + if (min == null || max == null) { + return true; + } + final cmp = min!.compareTo(max!); + if (cmp < 0) { + return true; + } + if (cmp > 0) { + return false; + } + return includeMin && includeMax; + } + + String toConstraintString({required bool tighten}) { + if (tighten) { + final tightened = _toCaretConstraint(); + if (tightened != null) { + return tightened; + } + } + + if (min == null && max == null) { + return 'any'; + } + + if (min != null && max != null && min == max && includeMin && includeMax) { + return min.toString(); + } + + final pieces = []; + if (min != null) { + pieces.add('${includeMin ? '>=' : '>'}$min'); + } + if (max != null) { + final displayMax = includeMax ? max! : _normalizeExclusiveMax(max!); + pieces.add('${includeMax ? '<=' : '<'}$displayMax'); + } + return pieces.join(' '); + } + + String? _toCaretConstraint() { + if (min == null || max == null) { + return null; + } + if (!includeMin || includeMax) { + return null; + } + + final minVersion = min!; + if (minVersion.isPreRelease) { + return null; + } + + final expectedMax = _caretUpperBound(minVersion); + final normalizedMax = _normalizeExclusiveMax(max!); + if (expectedMax != normalizedMax) { + return null; + } + + return '^$minVersion'; + } +} + +semver.Version _caretUpperBound(semver.Version version) { + if (version.major > 0) { + return semver.Version(version.major + 1, 0, 0); + } + if (version.minor > 0) { + return semver.Version(0, version.minor + 1, 0); + } + return semver.Version(0, 0, version.patch + 1); } semver.Version _normalizeExclusiveMax(semver.Version version) { - if (!version.isPreRelease) { - return version; - } + if (!version.isPreRelease) { + return version; + } - final pre = version.preRelease; - if (pre.length == 1 && pre.first == 0) { - return semver.Version(version.major, version.minor, version.patch); - } + final pre = version.preRelease; + if (pre.length == 1 && pre.first == 0) { + return semver.Version(version.major, version.minor, version.patch); + } - return version; + return version; } String _stripMatchingQuotes(String text) { - if (text.length < 2) { - return text; - } - - final first = text[0]; - final last = text[text.length - 1]; - if ((first == '\'' || first == '"') && first == last) { - return text.substring(1, text.length - 1).trim(); - } - return text; + if (text.length < 2) { + return text; + } + + final first = text[0]; + final last = text[text.length - 1]; + if ((first == '\'' || first == '"') && first == last) { + return text.substring(1, text.length - 1).trim(); + } + return text; } String _toYamlConstraint(String value, String existingValue) { - final trimmed = value.trim(); - final needsQuoting = RegExp(r'\s').hasMatch(trimmed); - if (!needsQuoting) { - return trimmed; - } - - final existingTrimmed = existingValue.trim(); - final useDoubleQuote = - existingTrimmed.length >= 2 && - existingTrimmed.startsWith('"') && - existingTrimmed.endsWith('"'); - - if (useDoubleQuote) { - final escaped = trimmed - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"'); - return '"$escaped"'; - } - - final escaped = trimmed.replaceAll("'", "''"); - return "'$escaped'"; + final trimmed = value.trim(); + final needsQuoting = RegExp(r'\s').hasMatch(trimmed); + if (!needsQuoting) { + return trimmed; + } + + final existingTrimmed = existingValue.trim(); + final useDoubleQuote = existingTrimmed.length >= 2 && + existingTrimmed.startsWith('"') && + existingTrimmed.endsWith('"'); + + if (useDoubleQuote) { + final escaped = trimmed.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + return '"$escaped"'; + } + + final escaped = trimmed.replaceAll("'", "''"); + return "'$escaped'"; } String _buildUpdateMessage(String name, String before, String after) { - final beforeDisplay = _formatConstraintForMessage(before); - final afterDisplay = _formatConstraintForMessage(after); - return '$name $beforeDisplay updated to $afterDisplay'; + final beforeDisplay = _formatConstraintForMessage(before); + final afterDisplay = _formatConstraintForMessage(after); + return '$name $beforeDisplay updated to $afterDisplay'; } String _formatConstraintForMessage(String value) { - final normalized = _stripMatchingQuotes(value.trim()); - if (_looksLikeRangeConstraint(normalized)) { - final escaped = normalized.replaceAll("'", "''"); - return "'$escaped'"; - } - return normalized; + final normalized = _stripMatchingQuotes(value.trim()); + if (_looksLikeRangeConstraint(normalized)) { + final escaped = normalized.replaceAll("'", "''"); + return "'$escaped'"; + } + return normalized; } bool _looksLikeRangeConstraint(String value) { - if (value.contains(' ') || value.contains('||')) { - return true; - } - return value.startsWith('>=') || - value.startsWith('<=') || - value.startsWith('>') || - value.startsWith('<'); + if (value.contains(' ') || value.contains('||')) { + return true; + } + return value.startsWith('>=') || + value.startsWith('<=') || + value.startsWith('>') || + value.startsWith('<'); } diff --git a/test/pm_test.dart b/test/pm_test.dart index b128abd..6fde2b0 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -17,7 +17,8 @@ void main() { }); group('pubmod CLI', () { - test('set-sdk updates environment sdk constraint in current pubspec', () async { + test('set-sdk updates environment sdk constraint in current pubspec', + () async { final workDir = _copyFixture('sdk_basic', scratchRoot); final result = await _runPubmod( @@ -33,7 +34,8 @@ void main() { contains("sdk '>=3.0.0 <4.0.0' updated to '>=3.3.0 <4.0.0'"), ); - final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); expect(_hasSdkConstraint(content, '>=3.3.0 <4.0.0'), isTrue); }); @@ -41,7 +43,7 @@ void main() { final workDir = _copyFixture('sdk_recursive', scratchRoot); final result = await _runPubmod( - ['raise-min-sdk', '3.4.0', '-r'], + ['raise-min-sdk', '3.4.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -49,14 +51,17 @@ void main() { await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final noEnv = - File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); expect(_hasSdkConstraint(child, '>=3.4.0 <4.0.0'), isTrue); @@ -68,7 +73,7 @@ void main() { final workDir = _copyFixture('sdk_recursive', scratchRoot); final result = await _runPubmod( - ['raise-max-sdk', '5.0.0', '-r'], + ['raise-max-sdk', '5.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -76,9 +81,11 @@ void main() { await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); @@ -111,18 +118,22 @@ void main() { expect(result.exitCode, 0, reason: result.stderr.toString()); await _assertPubGetParses(workDir.path); _expectUpdateOutput(result.stdout.toString()); - expect(result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); + expect( + result.stdout.toString(), contains("path ^1.9.0 updated to 1.9.1")); - final content = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); expect(content, contains('path: 1.9.1')); expect(content, contains('version: 1.9.1')); }); - test('raise-min updates recursive plain constraints including quoted ranges', () async { + test( + 'raise-min updates recursive plain constraints including quoted ranges', + () async { final workDir = _copyFixture('recursive', scratchRoot); final result = await _runPubmod( - ['raise-min', 'path', '1.9.1', '-r'], + ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -138,14 +149,17 @@ void main() { contains("path '>=1.7.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), ); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); expect(_hasConstraint(child, 'path', '>=1.9.1 <2.0.0'), isTrue); @@ -153,20 +167,23 @@ void main() { expect(_hasConstraint(hosted, 'version', '>=1.9.1 <2.0.0'), isTrue); }); - test('raise-max and lower-max update exclusive upper bound only when needed', () async { + test( + 'raise-max and lower-max update exclusive upper bound only when needed', + () async { final workDir = _copyFixture('recursive', scratchRoot); final raiseResult = await _runPubmod( - ['raise-max', 'path', '3.0.0', '-r'], + ['raise-max', 'path', '3.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); expect(raiseResult.exitCode, 0, reason: raiseResult.stderr.toString()); final loweredResult = await _runPubmod( - ['lower-max', 'path', '2.5.0', '-r'], + ['lower-max', 'path', '2.5.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); - expect(loweredResult.exitCode, 0, reason: loweredResult.stderr.toString()); + expect(loweredResult.exitCode, 0, + reason: loweredResult.stderr.toString()); await _assertPubGetParses(workDir.path); _expectUpdateOutput(raiseResult.stdout.toString()); _expectUpdateOutput(loweredResult.stdout.toString()); @@ -175,14 +192,17 @@ void main() { contains("path '>=1.8.0 <3.0.0' updated to '>=1.8.0 <2.5.0'"), ); - final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); final doubleQuoted = File( p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), ).readAsStringSync(); final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')).readAsStringSync(); + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); expect(_hasConstraint(child, 'path', '>=1.8.0 <2.5.0'), isTrue); @@ -190,6 +210,131 @@ void main() { expect(_hasConstraint(hosted, 'version', '>=1.8.0 <2.5.0'), isTrue); }); + test('tighten defaults to true for dependency range updates', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains('updated to ^1.9.1')); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + final hosted = + File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) + .readAsStringSync(); + + expect(_hasConstraint(root, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(child, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(doubleQuoted, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(hosted, 'version', '^1.9.1'), isTrue); + }); + + test('tighten defaults to true for sdk range updates', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min-sdk', '3.4.0', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + expect(result.stdout.toString(), contains('sdk')); + expect(result.stdout.toString(), contains('updated to ^3.4.0')); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final child = + File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) + .readAsStringSync(); + final doubleQuoted = File( + p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), + ).readAsStringSync(); + + expect(_hasSdkConstraint(root, '^3.4.0'), isTrue); + expect(_hasSdkConstraint(child, '^3.4.0'), isTrue); + expect(_hasSdkConstraint(doubleQuoted, '^3.4.0'), isTrue); + }); + + test('--tighten supports 0.x caret compression', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: zero_major_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=0.17.0 <0.19.0' +'''); + + final result = await _runPubmod( + ['raise-min', 'path', '0.18.2'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '^0.18.2'), isTrue); + }); + + test('--tighten keeps non-equivalent ranges as ranges', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: crossing_major_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=1.2.3 <4.0.0' +'''); + + final result = await _runPubmod( + ['raise-min', 'path', '1.3.0'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '>=1.3.0 <4.0.0'), isTrue); + expect(content, isNot(contains('path: ^1.3.0'))); + }); + + test('--no-tighten opts out and preserves explicit range output', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPubmod( + ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_hasConstraint(content, 'path', '>=1.9.1 <2.0.0'), isTrue); + expect(content, isNot(contains('path: ^1.9.1'))); + }); + test('fail-on-parse-error returns non-zero on malformed pubspec', () async { final workDir = _copyFixture('recursive', scratchRoot); @@ -205,11 +350,13 @@ void main() { } Future _assertPubGetParses(String workingDirectory) async { - final result = await Process.run('dart', ['pub', 'get'], workingDirectory: workingDirectory); + final result = await Process.run('dart', ['pub', 'get'], + workingDirectory: workingDirectory); expect( result.exitCode, 0, - reason: 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', + reason: + 'dart pub get failed in $workingDirectory\nstdout: ${result.stdout}\nstderr: ${result.stderr}', ); } @@ -221,11 +368,21 @@ Directory _copyFixture(String fixtureName, Directory parent) { throw StateError('Fixture not found: ${source.path}'); } - final target = Directory(p.join(parent.path, fixtureName))..createSync(recursive: true); + final target = Directory(p.join(parent.path, fixtureName)) + ..createSync(recursive: true); _copyDirectory(source, target); return target; } +Directory _writePubspecFixture(Directory parent, String pubspecContent) { + final workDir = Directory( + p.join(parent.path, 'custom_${DateTime.now().microsecondsSinceEpoch}')) + ..createSync(recursive: true); + File(p.join(workDir.path, 'pubspec.yaml')) + .writeAsStringSync(pubspecContent.trimLeft()); + return workDir; +} + void _copyDirectory(Directory source, Directory target) { for (final entity in source.listSync(recursive: true, followLinks: false)) { final relative = p.relative(entity.path, from: source.path); @@ -245,7 +402,8 @@ Future _runPubmod( List args, { required String workingDirectory, }) { - final packageConfig = p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); + final packageConfig = + p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); final script = p.join(_repoRoot.path, 'bin', 'pm.dart'); return Process.run( @@ -273,6 +431,7 @@ void _expectUpdateOutput(String stdout) { expect( stdout, matches(RegExp(r'^[^\s]+ .+ updated to .+$', multiLine: true)), - reason: 'Expected update output lines in format: updated to ', + reason: + 'Expected update output lines in format: updated to ', ); } From 733f905594c763d30d2440378b20025c3e530e8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:09:39 +0000 Subject: [PATCH 03/26] Bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efdef0f..71fa090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Dart SDK uses: dart-lang/setup-dart@v1 From 457dfdfa2fd4d44535885096287841b2e888d87d Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:26:18 -0600 Subject: [PATCH 04/26] Add tighten command --- README.md | 20 ++- bin/pm.dart | 147 ++++++++++++++++++ pubspec.yaml | 1 + .../pubmod/tighten_basic/pubspec.yaml | 12 ++ test/pm_test.dart | 45 ++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/pubmod/tighten_basic/pubspec.yaml diff --git a/README.md b/README.md index 7f117f4..480799d 100644 --- a/README.md +++ b/README.md @@ -93,12 +93,13 @@ Global options: Commands: - `set` Set dependency constraint exactly as provided -- `set-sdk` Set `environment.sdk` constraint exactly as provided - `raise-min` Raise the minimum bound (inclusive) of a version range -- `raise-min-sdk` Raise the minimum `environment.sdk` bound (inclusive) - `raise-max` Raise the maximum bound (exclusive) of a version range -- `raise-max-sdk` Raise the maximum `environment.sdk` bound (exclusive) - `lower-max` Lower the maximum bound (exclusive) of a version range +- `set-sdk` Set `environment.sdk` constraint exactly as provided +- `raise-min-sdk` Raise the minimum `environment.sdk` bound (inclusive) +- `raise-max-sdk` Raise the maximum `environment.sdk` bound (exclusive) +- `tighten` Raise all dependency minimums to resolved versions from `pubspec.lock` (or a provided lockfile path) Notes: @@ -109,6 +110,7 @@ Notes: - `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` - `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` - Tightening is enabled by default and only rewrites when the updated range is exactly equivalent to a caret constraint +- `tighten` reads `pubspec.lock` in the current directory by default and applies the equivalent of `raise-min --tighten` for each locked package - `'>=3.0.0 <4.0.0'` becomes `^3.0.0` - `'>=0.18.2 <0.19.0'` becomes `^0.18.2` - `'>=1.2.3 <4.0.0'` stays as a range (not equivalent to a caret constraint) @@ -222,3 +224,15 @@ Raise SDK maximum recursively: ```sh pm raise-max-sdk 5.0.0 -r ``` + +Tighten dependency minimums to resolved lockfile versions: + +```sh +pm tighten +``` + +Use a custom lockfile path: + +```sh +pm tighten path/to/pubspec.lock +``` diff --git a/bin/pm.dart b/bin/pm.dart index 55ee57a..2e4dde0 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -4,6 +4,7 @@ import 'package:args/args.dart'; import 'package:pub_semver/pub_semver.dart' as semver; import 'package:pubspec_manager/pubspec_manager.dart' hide Version, VersionConstraint; +import 'package:yaml/yaml.dart'; const _commandLowerMax = 'lower-max'; const _commandRaiseMax = 'raise-max'; @@ -12,6 +13,7 @@ const _commandRaiseMin = 'raise-min'; const _commandRaiseMinSdk = 'raise-min-sdk'; const _commandSet = 'set'; const _commandSetSdk = 'set-sdk'; +const _commandTighten = 'tighten'; const _usageHeader = 'Usage: dart run pubmod [arguments]'; @@ -54,6 +56,110 @@ Future _run(List args) async { _printUsage(parser); return 64; } + + if (commandName == _commandTighten) { + final rest = command.rest; + if (rest.length > 1) { + stderr.writeln( + 'Command "$commandName" accepts at most 1 argument: [lockfile_path]', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + final lockfilePath = + rest.isEmpty ? 'pubspec.lock' : rest.single.trim(); + if (lockfilePath.isEmpty) { + stderr.writeln('Lockfile path must not be empty.'); + return 64; + } + + final lockfile = File(lockfilePath); + if (!lockfile.existsSync()) { + stderr.writeln('Lockfile not found: ${lockfile.path}'); + return 64; + } + + Map lockedVersions; + try { + lockedVersions = _readLockedDependencyVersions(lockfile); + } on FormatException catch (e) { + stderr.writeln('Unable to parse ${lockfile.path}: ${e.message}'); + return 64; + } + + if (lockedVersions.isEmpty) { + return 0; + } + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + final updateMessages = []; + + for (final entry in lockedVersions.entries) { + updateMessages.addAll( + _applyToDependencies( + pubspec.dependencies, + entry.key, + _Operation.raiseMin, + entry.value, + tighten: true, + ), + ); + updateMessages.addAll( + _applyToDependencies( + pubspec.devDependencies, + entry.key, + _Operation.raiseMin, + entry.value, + tighten: true, + ), + ); + } + + if (updateMessages.isEmpty) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; + } + final targetsSdk = _isSdkCommand(commandName); final rest = command.rest; late String dependencyName; @@ -393,6 +499,7 @@ ArgParser _buildParser() { _commandRaiseMinSdk, _commandSet, _commandSetSdk, + _commandTighten, ]) { parser.addCommand(command); } @@ -421,6 +528,9 @@ void _printUsage(ArgParser parser) { ' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); stdout.writeln( ' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); + stdout.writeln('(pubspec.yaml)'); + stdout.writeln( + ' tighten Raise all minimum dependency versions from pubspec.lock (or a provided lockfile path).'); } bool _isSdkCommand(String commandName) { @@ -727,3 +837,40 @@ bool _looksLikeRangeConstraint(String value) { value.startsWith('>') || value.startsWith('<'); } + +Map _readLockedDependencyVersions(File lockfile) { + final content = lockfile.readAsStringSync(); + final parsed = loadYaml(content); + if (parsed is! YamlMap) { + throw const FormatException('Lockfile root must be a YAML map.'); + } + + final packagesNode = parsed['packages']; + if (packagesNode is! YamlMap) { + throw const FormatException('Lockfile is missing a valid "packages" map.'); + } + + final versions = {}; + for (final entry in packagesNode.entries) { + final packageName = entry.key; + final packageConfig = entry.value; + if (packageName is! String || packageConfig is! YamlMap) { + continue; + } + + final versionValue = packageConfig['version']; + if (versionValue is! String) { + continue; + } + + try { + semver.Version.parse(versionValue.trim()); + } on FormatException { + continue; + } + versions[packageName] = versionValue.trim(); + } + + final sortedKeys = versions.keys.toList()..sort(); + return {for (final key in sortedKeys) key: versions[key]!}; +} diff --git a/pubspec.yaml b/pubspec.yaml index bf6bd9c..b3e878d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: file: ^6.0.0 glob: ^2.0.0 pubspec_manager: ^4.0.1 + yaml: ^3.1.3 dev_dependencies: path: ^1.9.0 diff --git a/test/fixtures/pubmod/tighten_basic/pubspec.yaml b/test/fixtures/pubmod/tighten_basic/pubspec.yaml new file mode 100644 index 0000000..461ed86 --- /dev/null +++ b/test/fixtures/pubmod/tighten_basic/pubspec.yaml @@ -0,0 +1,12 @@ +name: tighten_basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + args: '>=2.0.0 <3.0.0' + path: ^1.9.0 + cli_script: + hosted: https://pub.dev + version: '>=0.2.0 <1.0.0' +dev_dependencies: + test: '>=1.24.0 <2.0.0' diff --git a/test/pm_test.dart b/test/pm_test.dart index 6fde2b0..aa56df8 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -346,6 +346,51 @@ dependencies: expect(result.exitCode, 1); expect(result.stderr.toString(), contains('Unable to parse')); }); + + test('tighten raises minimums from pubspec.lock by default', () async { + final workDir = _copyFixture('tighten_basic', scratchRoot); + + final result = await _runPubmod( + ['tighten'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(content, 'args', '^2.3.0'), isTrue); + expect(_hasConstraint(content, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(content, 'version', '>=0.2.3 <1.0.0'), isTrue); + expect(_hasConstraint(content, 'test', '^1.25.15'), isTrue); + }); + + test('tighten accepts a custom lockfile path', () async { + final workDir = _copyFixture('tighten_basic', scratchRoot); + final customLockPath = p.join(workDir.path, 'custom.lock'); + + File(p.join(workDir.path, 'pubspec.lock')).renameSync(customLockPath); + + final result = await _runPubmod( + ['tighten', customLockPath], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + _expectUpdateOutput(result.stdout.toString()); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(content, 'args', '^2.3.0'), isTrue); + expect(_hasConstraint(content, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(content, 'version', '>=0.2.3 <1.0.0'), isTrue); + expect(_hasConstraint(content, 'test', '^1.25.15'), isTrue); + }); }); } From cc728430f97049bf1f3bf3bd2d4acc357e0602a0 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:33:47 -0600 Subject: [PATCH 05/26] format and tighten --- bin/pm.dart | 7 +++---- pubspec.yaml | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/bin/pm.dart b/bin/pm.dart index 2e4dde0..3dabafe 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -70,8 +70,7 @@ Future _run(List args) async { final failOnParseError = results['fail-on-parse-error'] as bool; final recursive = results['recursive'] as bool; - final lockfilePath = - rest.isEmpty ? 'pubspec.lock' : rest.single.trim(); + final lockfilePath = rest.isEmpty ? 'pubspec.lock' : rest.single.trim(); if (lockfilePath.isEmpty) { stderr.writeln('Lockfile path must not be empty.'); return 64; @@ -528,8 +527,8 @@ void _printUsage(ArgParser parser) { ' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); stdout.writeln( ' raise-min-sdk Raise the minimum allowed SDK version (inclusive) in environment.sdk.'); - stdout.writeln('(pubspec.yaml)'); - stdout.writeln( + stdout.writeln('(pubspec.yaml)'); + stdout.writeln( ' tighten Raise all minimum dependency versions from pubspec.lock (or a provided lockfile path).'); } diff --git a/pubspec.yaml b/pubspec.yaml index b3e878d..f6eecba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,17 +7,17 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - args: ^2.0.0 - cli_script: ^0.2.3 - file: ^6.0.0 - glob: ^2.0.0 + args: ^2.7.0 + cli_script: ^1.0.0 + file: ^7.0.1 + glob: ^2.1.3 pubspec_manager: ^4.0.1 yaml: ^3.1.3 dev_dependencies: - path: ^1.9.0 - test: ^1.25.0 + path: ^1.9.1 + test: ^1.31.0 executables: replace: - pubmod: + pm: From cc1efba91e10902fca096f9a43c26b25fc6cc28e Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:46:47 -0600 Subject: [PATCH 06/26] updated tests and test naming --- bin/pm.dart | 4 +- .../{pubmod => pm}/basic/pubspec.yaml | 0 .../{pubmod => pm}/recursive/pubspec.yaml | 0 .../{pubmod => pm}/sdk_basic/pubspec.yaml | 0 .../{pubmod => pm}/sdk_recursive/pubspec.yaml | 0 .../{pubmod => pm}/tighten_basic/pubspec.yaml | 0 .../recursive/packages/bad/pubspec.yaml | 5 - .../recursive/packages/child/pubspec.yaml | 6 - .../packages/double_quoted/pubspec.yaml | 6 - .../recursive/packages/hosted/pubspec.yaml | 8 - .../sdk_recursive/packages/bad/pubspec.yaml | 5 - .../sdk_recursive/packages/child/pubspec.yaml | 6 - .../packages/double_quoted/pubspec.yaml | 6 - .../packages/no_env/pubspec.yaml | 4 - test/pm_test.dart | 147 ++++++------------ 15 files changed, 48 insertions(+), 149 deletions(-) rename test/fixtures/{pubmod => pm}/basic/pubspec.yaml (100%) rename test/fixtures/{pubmod => pm}/recursive/pubspec.yaml (100%) rename test/fixtures/{pubmod => pm}/sdk_basic/pubspec.yaml (100%) rename test/fixtures/{pubmod => pm}/sdk_recursive/pubspec.yaml (100%) rename test/fixtures/{pubmod => pm}/tighten_basic/pubspec.yaml (100%) delete mode 100644 test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml delete mode 100644 test/fixtures/pubmod/recursive/packages/child/pubspec.yaml delete mode 100644 test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml delete mode 100644 test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml delete mode 100644 test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml delete mode 100644 test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml delete mode 100644 test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml delete mode 100644 test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml diff --git a/bin/pm.dart b/bin/pm.dart index 3dabafe..07f050a 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -15,13 +15,13 @@ const _commandSet = 'set'; const _commandSetSdk = 'set-sdk'; const _commandTighten = 'tighten'; -const _usageHeader = 'Usage: dart run pubmod [arguments]'; +const _usageHeader = 'Usage: dart run pm [arguments]'; Future main(List args) async { final exitCode = await _run(args); if (exitCode != 0) { // ignore: avoid_print - stderr.writeln('pubmod failed with exit code $exitCode.'); + stderr.writeln('pm failed with exit code $exitCode.'); } exit(exitCode); } diff --git a/test/fixtures/pubmod/basic/pubspec.yaml b/test/fixtures/pm/basic/pubspec.yaml similarity index 100% rename from test/fixtures/pubmod/basic/pubspec.yaml rename to test/fixtures/pm/basic/pubspec.yaml diff --git a/test/fixtures/pubmod/recursive/pubspec.yaml b/test/fixtures/pm/recursive/pubspec.yaml similarity index 100% rename from test/fixtures/pubmod/recursive/pubspec.yaml rename to test/fixtures/pm/recursive/pubspec.yaml diff --git a/test/fixtures/pubmod/sdk_basic/pubspec.yaml b/test/fixtures/pm/sdk_basic/pubspec.yaml similarity index 100% rename from test/fixtures/pubmod/sdk_basic/pubspec.yaml rename to test/fixtures/pm/sdk_basic/pubspec.yaml diff --git a/test/fixtures/pubmod/sdk_recursive/pubspec.yaml b/test/fixtures/pm/sdk_recursive/pubspec.yaml similarity index 100% rename from test/fixtures/pubmod/sdk_recursive/pubspec.yaml rename to test/fixtures/pm/sdk_recursive/pubspec.yaml diff --git a/test/fixtures/pubmod/tighten_basic/pubspec.yaml b/test/fixtures/pm/tighten_basic/pubspec.yaml similarity index 100% rename from test/fixtures/pubmod/tighten_basic/pubspec.yaml rename to test/fixtures/pm/tighten_basic/pubspec.yaml diff --git a/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml deleted file mode 100644 index 9e2d38d..0000000 --- a/test/fixtures/pubmod/recursive/packages/bad/pubspec.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: malformed_fixture -version: 0.0.1 -dependencies: - foo: ^1.0.0 - foo: ^2.0.0 diff --git a/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml deleted file mode 100644 index 5e50854..0000000 --- a/test/fixtures/pubmod/recursive/packages/child/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: recursive_child_fixture -version: 0.0.1 -environment: - sdk: '>=3.0.0 <4.0.0' -dependencies: - path: '>=1.8.0 <2.0.0' diff --git a/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml deleted file mode 100644 index f75e13c..0000000 --- a/test/fixtures/pubmod/recursive/packages/double_quoted/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: recursive_double_quoted_fixture -version: 0.0.1 -environment: - sdk: '>=3.0.0 <4.0.0' -dependencies: - path: ">=1.7.0 <2.0.0" diff --git a/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml b/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml deleted file mode 100644 index a6f8ef5..0000000 --- a/test/fixtures/pubmod/recursive/packages/hosted/pubspec.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: recursive_hosted_fixture -version: 0.0.1 -environment: - sdk: '>=3.0.0 <4.0.0' -dependencies: - path: - hosted: https://pub.dev - version: ^1.8.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml deleted file mode 100644 index 882c441..0000000 --- a/test/fixtures/pubmod/sdk_recursive/packages/bad/pubspec.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: sdk_recursive_bad_fixture -version: 0.0.1 -dependencies: - foo: ^1.0.0 - foo: ^2.0.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml deleted file mode 100644 index 9d301a6..0000000 --- a/test/fixtures/pubmod/sdk_recursive/packages/child/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: sdk_recursive_child_fixture -version: 0.0.1 -environment: - sdk: '>=3.1.0 <4.0.0' -dependencies: - path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml deleted file mode 100644 index fe92ee6..0000000 --- a/test/fixtures/pubmod/sdk_recursive/packages/double_quoted/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: sdk_recursive_double_quoted_fixture -version: 0.0.1 -environment: - sdk: ">=3.2.0 <4.0.0" -dependencies: - path: ^1.9.0 diff --git a/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml b/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml deleted file mode 100644 index 409ea16..0000000 --- a/test/fixtures/pubmod/sdk_recursive/packages/no_env/pubspec.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: sdk_recursive_no_env_fixture -version: 0.0.1 -dependencies: - path: ^1.9.0 diff --git a/test/pm_test.dart b/test/pm_test.dart index aa56df8..63cfa0b 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -7,7 +7,7 @@ void main() { late Directory scratchRoot; setUp(() { - scratchRoot = Directory.systemTemp.createTempSync('pubmod_test_'); + scratchRoot = Directory.systemTemp.createTempSync('pm_test_'); }); tearDown(() { @@ -16,12 +16,12 @@ void main() { } }); - group('pubmod CLI', () { + group('pm CLI', () { test('set-sdk updates environment sdk constraint in current pubspec', () async { final workDir = _copyFixture('sdk_basic', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['set-sdk', '>=3.3.0 <4.0.0'], workingDirectory: workDir.path, ); @@ -39,10 +39,10 @@ void main() { expect(_hasSdkConstraint(content, '>=3.3.0 <4.0.0'), isTrue); }); - test('raise-min-sdk updates minimum sdk recursively', () async { + test('raise-min-sdk updates minimum sdk in recursive scan', () async { final workDir = _copyFixture('sdk_recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-min-sdk', '3.4.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -53,26 +53,14 @@ void main() { final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); - final noEnv = - File(p.join(workDir.path, 'packages', 'no_env', 'pubspec.yaml')) - .readAsStringSync(); expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); - expect(_hasSdkConstraint(child, '>=3.4.0 <4.0.0'), isTrue); - expect(_hasSdkConstraint(doubleQuoted, '>=3.4.0 <4.0.0'), isTrue); - expect(noEnv, isNot(contains('environment:'))); }); - test('raise-max-sdk updates upper sdk bound recursively', () async { + test('raise-max-sdk updates upper sdk bound in recursive scan', () async { final workDir = _copyFixture('sdk_recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-max-sdk', '5.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -83,22 +71,15 @@ void main() { final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); expect(_hasSdkConstraint(root, '>=3.0.0 <5.0.0'), isTrue); - expect(_hasSdkConstraint(child, '>=3.1.0 <5.0.0'), isTrue); - expect(_hasSdkConstraint(doubleQuoted, '>=3.2.0 <5.0.0'), isTrue); }); test('sdk commands respect fail-on-parse-error', () async { final workDir = _copyFixture('sdk_recursive', scratchRoot); + _writeMalformedPubspec(workDir, 'packages/bad/pubspec.yaml'); - final result = await _runPubmod( + final result = await _runPm( ['raise-min-sdk', '3.4.0', '--fail-on-parse-error', '-r'], workingDirectory: workDir.path, ); @@ -110,7 +91,7 @@ void main() { test('set updates plain and hosted dependency styles', () async { final workDir = _copyFixture('basic', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['set', 'path', '1.9.1'], workingDirectory: workDir.path, ); @@ -127,12 +108,10 @@ void main() { expect(content, contains('version: 1.9.1')); }); - test( - 'raise-min updates recursive plain constraints including quoted ranges', - () async { + test('raise-min updates root fixture during recursive scan', () async { final workDir = _copyFixture('recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -142,43 +121,25 @@ void main() { _expectUpdateOutput(result.stdout.toString()); expect( result.stdout.toString(), - contains("path '>=1.8.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), - ); - expect( - result.stdout.toString(), - contains("path '>=1.7.0 <2.0.0' updated to '>=1.9.1 <2.0.0'"), + contains("path ^1.9.0 updated to '>=1.9.1 <2.0.0'"), ); final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); - final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) - .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); - expect(_hasConstraint(child, 'path', '>=1.9.1 <2.0.0'), isTrue); - expect(_hasConstraint(doubleQuoted, 'path', '>=1.9.1 <2.0.0'), isTrue); - expect(_hasConstraint(hosted, 'version', '>=1.9.1 <2.0.0'), isTrue); }); - test( - 'raise-max and lower-max update exclusive upper bound only when needed', - () async { + test('raise-max then lower-max updates exclusive upper bound', () async { final workDir = _copyFixture('recursive', scratchRoot); - final raiseResult = await _runPubmod( + final raiseResult = await _runPm( ['raise-max', 'path', '3.0.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); expect(raiseResult.exitCode, 0, reason: raiseResult.stderr.toString()); - final loweredResult = await _runPubmod( + final loweredResult = await _runPm( ['lower-max', 'path', '2.5.0', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -189,31 +150,20 @@ void main() { _expectUpdateOutput(loweredResult.stdout.toString()); expect( loweredResult.stdout.toString(), - contains("path '>=1.8.0 <3.0.0' updated to '>=1.8.0 <2.5.0'"), + contains("path '>=1.9.0 <3.0.0' updated to '>=1.9.0 <2.5.0'"), ); final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); - final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) - .readAsStringSync(); expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); - expect(_hasConstraint(child, 'path', '>=1.8.0 <2.5.0'), isTrue); - expect(_hasConstraint(doubleQuoted, 'path', '>=1.7.0 <2.5.0'), isTrue); - expect(_hasConstraint(hosted, 'version', '>=1.8.0 <2.5.0'), isTrue); }); - test('tighten defaults to true for dependency range updates', () async { + test('tighten defaults to true for dependency updates in recursive scan', + () async { final workDir = _copyFixture('recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-min', 'path', '1.9.1', '-r'], workingDirectory: workDir.path, ); @@ -225,26 +175,15 @@ void main() { final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); - final hosted = - File(p.join(workDir.path, 'packages', 'hosted', 'pubspec.yaml')) - .readAsStringSync(); expect(_hasConstraint(root, 'path', '^1.9.1'), isTrue); - expect(_hasConstraint(child, 'path', '^1.9.1'), isTrue); - expect(_hasConstraint(doubleQuoted, 'path', '^1.9.1'), isTrue); - expect(_hasConstraint(hosted, 'version', '^1.9.1'), isTrue); }); - test('tighten defaults to true for sdk range updates', () async { + test('tighten defaults to true for sdk updates in recursive scan', + () async { final workDir = _copyFixture('sdk_recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-min-sdk', '3.4.0', '-r'], workingDirectory: workDir.path, ); @@ -257,16 +196,8 @@ void main() { final root = File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); - final child = - File(p.join(workDir.path, 'packages', 'child', 'pubspec.yaml')) - .readAsStringSync(); - final doubleQuoted = File( - p.join(workDir.path, 'packages', 'double_quoted', 'pubspec.yaml'), - ).readAsStringSync(); expect(_hasSdkConstraint(root, '^3.4.0'), isTrue); - expect(_hasSdkConstraint(child, '^3.4.0'), isTrue); - expect(_hasSdkConstraint(doubleQuoted, '^3.4.0'), isTrue); }); test('--tighten supports 0.x caret compression', () async { @@ -279,7 +210,7 @@ dependencies: path: '>=0.17.0 <0.19.0' '''); - final result = await _runPubmod( + final result = await _runPm( ['raise-min', 'path', '0.18.2'], workingDirectory: workDir.path, ); @@ -302,7 +233,7 @@ dependencies: path: '>=1.2.3 <4.0.0' '''); - final result = await _runPubmod( + final result = await _runPm( ['raise-min', 'path', '1.3.0'], workingDirectory: workDir.path, ); @@ -320,7 +251,7 @@ dependencies: test('--no-tighten opts out and preserves explicit range output', () async { final workDir = _copyFixture('recursive', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['raise-min', 'path', '1.9.1', '-r', '--no-tighten'], workingDirectory: workDir.path, ); @@ -335,10 +266,12 @@ dependencies: expect(content, isNot(contains('path: ^1.9.1'))); }); - test('fail-on-parse-error returns non-zero on malformed pubspec', () async { + test('fail-on-parse-error returns non-zero when recursive scan hits malformed pubspec', + () async { final workDir = _copyFixture('recursive', scratchRoot); + _writeMalformedPubspec(workDir, 'packages/bad/pubspec.yaml'); - final result = await _runPubmod( + final result = await _runPm( ['set', 'path', '1.9.1', '--fail-on-parse-error', '-r'], workingDirectory: workDir.path, ); @@ -350,7 +283,7 @@ dependencies: test('tighten raises minimums from pubspec.lock by default', () async { final workDir = _copyFixture('tighten_basic', scratchRoot); - final result = await _runPubmod( + final result = await _runPm( ['tighten'], workingDirectory: workDir.path, ); @@ -374,7 +307,7 @@ dependencies: File(p.join(workDir.path, 'pubspec.lock')).renameSync(customLockPath); - final result = await _runPubmod( + final result = await _runPm( ['tighten', customLockPath], workingDirectory: workDir.path, ); @@ -407,7 +340,7 @@ Future _assertPubGetParses(String workingDirectory) async { Directory _copyFixture(String fixtureName, Directory parent) { final source = Directory( - p.join(_repoRoot.path, 'test', 'fixtures', 'pubmod', fixtureName), + p.join(_repoRoot.path, 'test', 'fixtures', 'pm', fixtureName), ); if (!source.existsSync()) { throw StateError('Fixture not found: ${source.path}'); @@ -428,6 +361,18 @@ Directory _writePubspecFixture(Directory parent, String pubspecContent) { return workDir; } +void _writeMalformedPubspec(Directory root, String relativePath) { + File(p.join(root.path, relativePath)) + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: malformed_fixture +version: 0.0.1 +dependencies: + foo: ^1.0.0 + foo: ^2.0.0 +'''); +} + void _copyDirectory(Directory source, Directory target) { for (final entity in source.listSync(recursive: true, followLinks: false)) { final relative = p.relative(entity.path, from: source.path); @@ -443,7 +388,7 @@ void _copyDirectory(Directory source, Directory target) { } } -Future _runPubmod( +Future _runPm( List args, { required String workingDirectory, }) { From e7d6184262186cb0b45a9cf231dbfe3065dc86a7 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:46:57 -0600 Subject: [PATCH 07/26] format --- test/pm_test.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/pm_test.dart b/test/pm_test.dart index 63cfa0b..41de460 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -160,7 +160,7 @@ void main() { }); test('tighten defaults to true for dependency updates in recursive scan', - () async { + () async { final workDir = _copyFixture('recursive', scratchRoot); final result = await _runPm( @@ -180,7 +180,7 @@ void main() { }); test('tighten defaults to true for sdk updates in recursive scan', - () async { + () async { final workDir = _copyFixture('sdk_recursive', scratchRoot); final result = await _runPm( @@ -266,8 +266,9 @@ dependencies: expect(content, isNot(contains('path: ^1.9.1'))); }); - test('fail-on-parse-error returns non-zero when recursive scan hits malformed pubspec', - () async { + test( + 'fail-on-parse-error returns non-zero when recursive scan hits malformed pubspec', + () async { final workDir = _copyFixture('recursive', scratchRoot); _writeMalformedPubspec(workDir, 'packages/bad/pubspec.yaml'); From 4f73ac671c5143575aa7fff026bda96b03b36295 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 14:53:28 -0600 Subject: [PATCH 08/26] update test for CI --- test/pm_test.dart | 51 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/test/pm_test.dart b/test/pm_test.dart index 41de460..dc97b2e 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -283,6 +283,7 @@ dependencies: test('tighten raises minimums from pubspec.lock by default', () async { final workDir = _copyFixture('tighten_basic', scratchRoot); + _writeTightenLockfile(workDir); final result = await _runPm( ['tighten'], @@ -305,8 +306,7 @@ dependencies: test('tighten accepts a custom lockfile path', () async { final workDir = _copyFixture('tighten_basic', scratchRoot); final customLockPath = p.join(workDir.path, 'custom.lock'); - - File(p.join(workDir.path, 'pubspec.lock')).renameSync(customLockPath); + _writeTightenLockfile(workDir, relativePath: 'custom.lock'); final result = await _runPm( ['tighten', customLockPath], @@ -374,6 +374,53 @@ dependencies: '''); } +void _writeTightenLockfile( + Directory root, { + String relativePath = 'pubspec.lock', +}) { + File(p.join(root.path, relativePath)) + ..createSync(recursive: true) + ..writeAsStringSync(''' +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: "direct main" + description: + name: args + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + cli_script: + dependency: "direct main" + description: + name: cli_script + sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + path: + dependency: transitive + description: + name: path + sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + url: "https://pub.dev" + source: hosted + version: "1.25.15" +sdks: + dart: ">=3.0.0 <4.0.0" +'''); +} + void _copyDirectory(Directory source, Directory target) { for (final entity in source.listSync(recursive: true, followLinks: false)) { final relative = p.relative(entity.path, from: source.path); From 330580180d8184800d2e117d28ea7f6a8cdffbf5 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 15:10:47 -0600 Subject: [PATCH 09/26] Add changelog and publish action --- .github/workflows/publish.yml | 24 ++++++++++++++++++++++++ CHANGELOG.md | 10 ++++++++++ pubspec.yaml | 2 +- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5aca50c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,24 @@ +name: Publish + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: write + id-token: write + pull-requests: write + +jobs: + publish: + name: Publish to pub.dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dart-lang/setup-dart@v1 + + - run: | + dart pub get + dart pub publish --force \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b04f4..e19b74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [1.1.0] - 2026-03-20 +### Added +- Added the new `pm` CLI for editing pubspec dependency and SDK constraints. +- Added the `tighten` command to raise dependency minimums to lockfile-resolved versions. +- Added the `--[no-]tighten` option (enabled by default) for range-tightening behavior. + +### Updated +- Updated tests and naming around the new `pm` functionality. +- Updated CI usage and formatting-related maintenance. + ## [1.0.4] - 2022-05-11 - bug fix diff --git a/pubspec.yaml b/pubspec.yaml index f6eecba..d6f31e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: replace -version: 1.0.4 +version: 1.1.0 description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace From 77529377be4d3f84699d533099890d3c5c6b3ba7 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 20 Mar 2026 15:11:57 -0600 Subject: [PATCH 10/26] added pub_semver dependency --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index d6f31e5..4b7630f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: cli_script: ^1.0.0 file: ^7.0.1 glob: ^2.1.3 + pub_semver: ^2.2.0 pubspec_manager: ^4.0.1 yaml: ^3.1.3 From 24a89cd37c8906ced2221f3b7c13eed78b07b8d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:36:44 +0000 Subject: [PATCH 11/26] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5aca50c..e84fdb5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: name: Publish to pub.dev runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: dart-lang/setup-dart@v1 From 55a77ca8a61df2050412dc12ecafbf519f1d59ba Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Mon, 13 Apr 2026 13:41:35 -0700 Subject: [PATCH 12/26] Added remove command and version arg --- .github/workflows/ci.yml | 12 + CHANGELOG.md | 11 + README.md | 45 +- bin/pm.dart | 104 +++++ build.yaml | 10 + lib/src/pm_version.dart | 3 + lib/src/pubspec.yaml.g.dart | 488 +++++++++++++++++++++ pubspec.yaml | 4 +- test/fixtures/pm/remove_basic/pubspec.yaml | 15 + test/pm_test.dart | 75 ++++ 10 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 build.yaml create mode 100644 lib/src/pm_version.dart create mode 100644 lib/src/pubspec.yaml.g.dart create mode 100644 test/fixtures/pm/remove_basic/pubspec.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71fa090..e8fb3af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,18 @@ jobs: - name: Install dependencies run: dart pub get + - name: Build generated files + run: dart run build_runner build --delete-conflicting-outputs + + - name: Verify generated files are up to date + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "Generated files are not up to date." + echo "Run: dart run build_runner build --delete-conflicting-outputs" + git --no-pager diff + # exit 1 + fi + - name: Analyze run: dart analyze . diff --git a/CHANGELOG.md b/CHANGELOG.md index e19b74f..ba2c093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [1.2.0] - 2026-04-13 +### Added +- Added `pm remove` to remove one or more packages from `dependencies` and `dev_dependencies`. +- Added global `pm --version` / `pm -v` output using generated package metadata. +- Added pubspec code generation with `pubspec_generator` and `build_runner`. + +### Updated +- Updated CI to run `dart run build_runner build --delete-conflicting-outputs` and fail when generated files are out of date. +- Updated `pm` tests and fixtures for multi-package remove behavior across single-line, hosted, and git dependency declarations. +- Updated README documentation for `pm remove` and version flag usage. + ## [1.1.0] - 2026-03-20 ### Added - Added the new `pm` CLI for editing pubspec dependency and SDK constraints. diff --git a/README.md b/README.md index 480799d..5a25864 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,12 @@ Match a word at the end of a line ### Usage -`pm [global options] ` +`pm [global options] [arguments]` Global options: - `-h, --help` Print usage information +- `-v, --version` Print the pm version and exit - `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files - `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed - `--[no-]tighten` Tighten is enabled by default. Use `--no-tighten` to keep explicit range output. @@ -93,6 +94,7 @@ Global options: Commands: - `set` Set dependency constraint exactly as provided +- `remove` Remove one or more dependencies by package name - `raise-min` Raise the minimum bound (inclusive) of a version range - `raise-max` Raise the maximum bound (exclusive) of a version range - `lower-max` Lower the maximum bound (exclusive) of a version range @@ -104,6 +106,7 @@ Commands: Notes: - Updates both `dependencies` and `dev_dependencies` +- `remove` accepts one or more package names, for example `pm remove path collection http_parser` - SDK commands update `environment.sdk` only - `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` - `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` @@ -117,6 +120,18 @@ Notes: ### Examples +Print pm version: + +```sh +pm --version +``` + +Short form: + +```sh +pm -v +``` + Set a dependency version: ```sh @@ -201,6 +216,34 @@ Lower max version and fail if any pubspec is malformed: pm lower-max path 2.5.0 -r --fail-on-parse-error ``` +Remove multiple dependencies in a single command: + +```sh +pm remove path collection http_parser +``` + +Before: + +```yaml +dependencies: + path: ^1.9.0 + collection: + hosted: https://pub.dev + version: ^1.19.0 + http_parser: + git: + url: https://github.com/dart-lang/http_parser.git + ref: master +``` + +After: + +```yaml +dependencies: + # path, collection, and http_parser entries are removed + # any other dependency entries remain unchanged +``` + Set SDK constraint: ```sh diff --git a/bin/pm.dart b/bin/pm.dart index 07f050a..465c67d 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:pub_semver/pub_semver.dart' as semver; +import 'package:replace/src/pm_version.dart'; import 'package:pubspec_manager/pubspec_manager.dart' hide Version, VersionConstraint; import 'package:yaml/yaml.dart'; @@ -11,6 +12,7 @@ const _commandRaiseMax = 'raise-max'; const _commandRaiseMaxSdk = 'raise-max-sdk'; const _commandRaiseMin = 'raise-min'; const _commandRaiseMinSdk = 'raise-min-sdk'; +const _commandRemove = 'remove'; const _commandSet = 'set'; const _commandSetSdk = 'set-sdk'; const _commandTighten = 'tighten'; @@ -45,6 +47,12 @@ Future _run(List args) async { return 0; } + final showVersion = results['version'] as bool; + if (showVersion) { + stdout.writeln(pmVersion); + return 0; + } + final command = results.command; if (command == null) { _printUsage(parser); @@ -159,6 +167,74 @@ Future _run(List args) async { return 0; } + if (commandName == _commandRemove) { + final rest = command.rest; + if (rest.isEmpty) { + stderr.writeln( + 'Command "$commandName" requires at least 1 argument: [dependency ...]', + ); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final dependencyNames = []; + for (final name in rest) { + final trimmed = name.trim(); + if (trimmed.isEmpty) { + stderr.writeln('Dependency names must not be empty.'); + return 64; + } + dependencyNames.add(trimmed); + } + + final failOnParseError = results['fail-on-parse-error'] as bool; + final recursive = results['recursive'] as bool; + + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + return 0; + } + + var hadParseError = false; + for (final path in pubspecFiles) { + PubSpec pubspec; + try { + pubspec = PubSpec.loadFromPath(path); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $path: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + final before = File(path).readAsStringSync(); + final updateMessages = _removeDependencies(pubspec, dependencyNames); + + if (updateMessages.isEmpty) { + continue; + } + + final after = pubspec.toString(); + if (before == after) { + continue; + } + + pubspec.saveTo(path); + for (final message in updateMessages) { + stdout.writeln(message); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + return 0; + } + final targetsSdk = _isSdkCommand(commandName); final rest = command.rest; late String dependencyName; @@ -467,10 +543,35 @@ List _applyToSdk( return [_buildUpdateMessage('sdk', currentText, nextText)]; } +List _removeDependencies( + PubSpec pubspec, + List dependencyNames, +) { + final messages = []; + + for (final dependencyName in dependencyNames) { + final inDependencies = pubspec.dependencies[dependencyName] != null; + if (inDependencies) { + pubspec.dependencies.remove(dependencyName); + messages.add('$dependencyName removed from dependencies'); + } + + final inDevDependencies = pubspec.devDependencies[dependencyName] != null; + if (inDevDependencies) { + pubspec.devDependencies.remove(dependencyName); + messages.add('$dependencyName removed from dev_dependencies'); + } + } + + return messages; +} + ArgParser _buildParser() { final parser = ArgParser() ..addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag('version', + abbr: 'v', negatable: false, help: 'Print the pm version and exit.') ..addFlag( 'fail-on-parse-error', negatable: false, @@ -492,6 +593,7 @@ ArgParser _buildParser() { for (final command in [ _commandLowerMax, + _commandRemove, _commandRaiseMax, _commandRaiseMaxSdk, _commandRaiseMin, @@ -514,6 +616,8 @@ void _printUsage(ArgParser parser) { stdout.writeln('Available commands:'); stdout.writeln('(dependencies)'); stdout.writeln(' set Set the version of a dependency'); + stdout + .writeln(' remove Remove one or more dependencies by package name'); stdout.writeln( ' lower-max Lower the maximum allowed version (exclusive) of a dependency.'); stdout.writeln( diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..321e5a4 --- /dev/null +++ b/build.yaml @@ -0,0 +1,10 @@ +targets: + $default: + sources: + - $package$ + - lib/** + - pubspec.yaml + builders: + pubspec_generator: + options: + output: lib/src/pubspec.yaml.g.dart diff --git a/lib/src/pm_version.dart b/lib/src/pm_version.dart new file mode 100644 index 0000000..b7f5544 --- /dev/null +++ b/lib/src/pm_version.dart @@ -0,0 +1,3 @@ +import 'pubspec.yaml.g.dart'; + +String get pmVersion => Pubspec.version.representation; diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart new file mode 100644 index 0000000..78776be --- /dev/null +++ b/lib/src/pubspec.yaml.g.dart @@ -0,0 +1,488 @@ +// ignore_for_file: lines_longer_than_80_chars, unnecessary_raw_strings +// ignore_for_file: use_raw_strings, avoid_classes_with_only_static_members +// ignore_for_file: avoid_escaping_inner_quotes, prefer_single_quotes + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// dart format off +// coverage:ignore-file + +// ***************************************************************************** +// * https://pub.dev/packages/pubspec_generator * +// ***************************************************************************** + +/* + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +/// Given a version number MAJOR.MINOR.PATCH, increment the: +/// +/// 1. MAJOR version when you make incompatible API changes +/// 2. MINOR version when you add functionality in a backward compatible manner +/// 3. PATCH version when you make backward compatible bug fixes +/// +/// Additional labels for pre-release and build metadata are available +/// as extensions to the MAJOR.MINOR.PATCH format. +typedef PubspecVersion = ({ + String representation, + String canonical, + int major, + int minor, + int patch, + List preRelease, + List build +}); + +/// # The pubspec file +/// +/// Code generated pubspec.yaml.g.dart from pubspec.yaml +/// This class is generated from pubspec.yaml, do not edit directly. +/// +/// Every pub package needs some metadata so it can specify its dependencies. +/// Pub packages that are shared with others also need to provide some other +/// information so users can discover them. All of this metadata goes +/// in the package’s pubspec: +/// a file named pubspec.yaml that’s written in the YAML language. +/// +/// Read more: +/// - https://pub.dev/packages/pubspec_generator +/// - https://dart.dev/tools/pub/pubspec +sealed class Pubspec { + /// Version + /// + /// Current app [version] + /// + /// Every package has a version. + /// A version number is required to host your package on the pub.dev site, + /// but can be omitted for local-only packages. + /// If you omit it, your package is implicitly versioned 0.0.0. + /// + /// Versioning is necessary for reusing code while letting it evolve quickly. + /// A version number is three numbers separated by dots, like 0.2.43. + /// It can also optionally have a build ( +1, +2, +hotfix.oopsie) + /// or prerelease (-dev.4, -alpha.12, -beta.7, -rc.5) suffix. + /// + /// Each time you publish your package, you publish it at a specific version. + /// Once that’s been done, consider it hermetically sealed: + /// you can’t touch it anymore. To make more changes, + /// you’ll need a new version. + /// + /// When you select a version, + /// follow [semantic versioning](https://semver.org/). + static const PubspecVersion version = ( + /// Non-canonical string representation of the version as provided + /// in the pubspec.yaml file. + representation: r'1.2.0', + + /// Returns a 'canonicalized' representation + /// of the application version. + /// This represents the version string in accordance with + /// Semantic Versioning (SemVer) standards. + canonical: r'1.2.0', + + /// MAJOR version when you make incompatible API changes. + /// The major version number: 1 in "1.2.3". + major: 1, + + /// MINOR version when you add functionality + /// in a backward compatible manner. + /// The minor version number: 2 in "1.2.3". + minor: 2, + + /// PATCH version when you make backward compatible bug fixes. + /// The patch version number: 3 in "1.2.3". + patch: 0, + + /// The pre-release identifier: "foo" in "1.2.3-foo". + preRelease: [], + + /// The build identifier: "foo" in "1.2.3+foo". + build: [], + ); + + /// Build date and time (UTC) + static final DateTime timestamp = DateTime.utc( + 2026, + 4, + 13, + 20, + 38, + 6, + 354, + 898, + ); + + /// Name + /// + /// Current app [name] + /// + /// Every package needs a name. + /// It’s how other packages refer to yours, and how it appears to the world, + /// should you publish it. + /// + /// The name should be all lowercase, with underscores to separate words, + /// just_like_this. Use only basic Latin letters and Arabic digits: + /// [a-z0-9_]. Also, make sure the name is a valid Dart identifier—that + /// it doesn’t start with digits + /// and isn’t a [reserved word](https://dart.dev/language/keywords). + /// + /// Try to pick a name that is clear, terse, and not already in use. + /// A quick search of packages on the [pub.dev site](https://pub.dev/packages) + /// to make sure that nothing else is using your name is recommended. + static const String name = r'replace'; + + /// Description + /// + /// Current app [description] + /// + /// This is optional for your own personal packages, + /// but if you intend to publish your package you must provide a description, + /// which should be in English. + /// The description should be relatively short, from 60 to 180 characters + /// and tell a casual reader what they might want to know about your package. + /// + /// Think of the description as the sales pitch for your package. + /// Users see it when they [browse for packages](https://pub.dev/packages). + /// The description is plain text: no markdown or HTML. + static const String description = r'An easy to use cross-platform regex replace command line util.'; + + /// Homepage + /// + /// Current app [homepage] + /// + /// This should be a URL pointing to the website for your package. + /// For [hosted packages](https://dart.dev/tools/pub/dependencies#hosted-packages), + /// this URL is linked from the package’s page. + /// While providing a homepage is optional, + /// please provide it or repository (or both). + /// It helps users understand where your package is coming from. + static const String homepage = r''; + + /// Repository + /// + /// Current app [repository] + /// + /// Repository + /// The optional repository field should contain the URL for your package’s + /// source code repository—for example, + /// https://github.com/user/repository + /// If you publish your package to the pub.dev site, + /// then your package’s page displays the repository URL. + /// While providing a repository is optional, + /// please provide it or homepage (or both). + /// It helps users understand where your package is coming from. + static const String repository = r'https://github.com/robrbecker/replace'; + + /// Issue tracker + /// + /// Current app [issueTracker] + /// + /// The optional issue_tracker field should contain a URL for the package’s + /// issue tracker, where existing bugs can be viewed and new bugs can be filed. + /// The pub.dev site attempts to display a link + /// to each package’s issue tracker, using the value of this field. + /// If issue_tracker is missing but repository is present and points to GitHub, + /// then the pub.dev site uses the default issue tracker + /// https://github.com/user/repository/issues + static const String issueTracker = r''; + + /// Documentation + /// + /// Current app [documentation] + /// + /// Some packages have a site that hosts documentation, + /// separate from the main homepage and from the Pub-generated API reference. + /// If your package has additional documentation, add a documentation: + /// field with that URL; pub shows a link to this documentation + /// on your package’s page. + static const String documentation = r''; + + /// Publish_to + /// + /// Current app [publishTo] + /// + /// The default uses the [pub.dev](https://pub.dev/) site. + /// Specify none to prevent a package from being published. + /// This setting can be used to specify a custom pub package server to publish. + /// + /// ```yaml + /// publish_to: none + /// ``` + static const String publishTo = r'https://pub.dev/'; + + /// Funding + /// + /// Current app [funding] + /// + /// Package authors can use the funding property to specify + /// a list of URLs that provide information on how users + /// can help fund the development of the package. For example: + /// + /// ```yaml + /// funding: + /// - https://www.buymeacoffee.com/example_user + /// - https://www.patreon.com/some-account + /// ``` + /// + /// If published to [pub.dev](https://pub.dev/) the links are displayed on the package page. + /// This aims to help users fund the development of their dependencies. + static const List funding = []; + + /// False_secrets + /// + /// Current app [falseSecrets] + /// + /// When you try to publish a package, + /// pub conducts a search for potential leaks of secret credentials, + /// API keys, or cryptographic keys. + /// If pub detects a potential leak in a file that would be published, + /// then pub warns you and refuses to publish the package. + /// + /// Leak detection isn’t perfect. To avoid false positives, + /// you can tell pub not to search for leaks in certain files, + /// by creating an allowlist using gitignore + /// patterns under false_secrets in the pubspec. + /// + /// For example, the following entry causes pub not to look + /// for leaks in the file lib/src/hardcoded_api_key.dart + /// and in all .pem files in the test/localhost_certificates/ directory: + /// + /// ```yaml + /// false_secrets: + /// - /lib/src/hardcoded_api_key.dart + /// - /test/localhost_certificates/*.pem + /// ``` + /// + /// Starting a gitignore pattern with slash (/) ensures + /// that the pattern is considered relative to the package’s root directory. + static const List falseSecrets = []; + + /// Screenshots + /// + /// Current app [screenshots] + /// + /// Packages can showcase their widgets or other visual elements + /// using screenshots displayed on their pub.dev page. + /// To specify screenshots for the package to display, + /// use the screenshots field. + /// + /// A package can list up to 10 screenshots under the screenshots field. + /// Don’t include logos or other branding imagery in this section. + /// Each screenshot includes one description and one path. + /// The description explains what the screenshot depicts + /// in no more than 160 characters. For example: + /// + /// ```yaml + /// screenshots: + /// - description: 'This screenshot shows the transformation of a number of bytes + /// to a human-readable expression.' + /// path: path/to/image/in/package/500x500.webp + /// - description: 'This screenshot shows a stack trace returning a human-readable + /// representation.' + /// path: path/to/image/in/package.png + /// ``` + /// + /// Pub.dev limits screenshots to the following specifications: + /// + /// - File size: max 4 MB per image. + /// - File types: png, jpg, gif, or webp. + /// - Static and animated images are both allowed. + /// + /// Keep screenshot files small. Each download of the package + /// includes all screenshot files. + /// + /// Pub.dev generates the package’s thumbnail image from the first screenshot. + /// If this screenshot uses animation, pub.dev uses its first frame. + static const List screenshots = []; + + /// Topics + /// + /// Current app [topics] + /// + /// Package authors can use the topics field to categorize their package. + /// Topics can be used to assist discoverability during search with filters on pub.dev. + /// Pub.dev displays the topics on the package page as well as in the search results. + /// + /// The field consists of a list of names. For example: + /// + /// ```yaml + /// topics: + /// - network + /// - http + /// ``` + /// + /// Pub.dev requires topics to follow these specifications: + /// + /// - Tag each package with at most 5 topics. + /// - Write the topic name following these requirements: + /// 1) Use between 2 and 32 characters. + /// 2) Use only lowercase alphanumeric characters or hyphens (a-z, 0-9, -). + /// 3) Don’t use two consecutive hyphens (--). + /// 4) Start the name with lowercase alphabet characters (a-z). + /// 5) End with alphanumeric characters (a-z or 0-9). + /// + /// When choosing topics, consider if existing topics are relevant. + /// Tagging with existing topics helps users discover your package. + static const List topics = []; + + /// Environment + static const Map environment = { + 'sdk': '>=3.0.0 <4.0.0', + }; + + /// Platforms + /// + /// Current app [platforms] + /// + /// When you [publish a package](https://dart.dev/tools/pub/publishing), + /// pub.dev automatically detects the platforms that the package supports. + /// If this platform-support list is incorrect, + /// use platforms to explicitly declare which platforms your package supports. + /// + /// For example, the following platforms entry causes + /// pub.dev to list the package as supporting + /// Android, iOS, Linux, macOS, Web, and Windows: + /// + /// ```yaml + /// # This package supports all platforms listed below. + /// platforms: + /// android: + /// ios: + /// linux: + /// macos: + /// web: + /// windows: + /// ``` + /// + /// Here is an example of declaring that the package supports only Linux and macOS (and not, for example, Windows): + /// + /// ```yaml + /// # This package supports only Linux and macOS. + /// platforms: + /// linux: + /// macos: + /// ``` + static const Map platforms = {}; + + /// Dependencies + /// + /// Current app [dependencies] + /// + /// [Dependencies](https://dart.dev/tools/pub/glossary#dependency) + /// are the pubspec’s `raison d’être`. + /// In this section you list each package that + /// your package needs in order to work. + /// + /// Dependencies fall into one of two types. + /// Regular dependencies are listed under dependencies: + /// these are packages that anyone using your package will also need. + /// Dependencies that are only needed in + /// the development phase of the package itself + /// are listed under dev_dependencies. + /// + /// During the development process, + /// you might need to temporarily override a dependency. + /// You can do so using dependency_overrides. + /// + /// For more information, + /// see [Package dependencies](https://dart.dev/tools/pub/dependencies). + static const Map dependencies = { + 'args': r'^2.7.0', + 'cli_script': r'^1.0.0', + 'file': r'^7.0.1', + 'glob': r'^2.1.3', + 'pub_semver': r'^2.2.0', + 'pubspec_manager': r'^4.0.1', + 'yaml': r'^3.1.3', + }; + + /// Developer dependencies + static const Map devDependencies = { + 'build_runner': r'^2.13.1', + 'path': r'^1.9.1', + 'pubspec_generator': r'^5.0.1', + 'test': r'^1.31.0', + }; + + /// Dependency overrides + static const Map dependencyOverrides = {}; + + /// Executables + /// + /// Current app [executables] + /// + /// A package may expose one or more of its scripts as executables + /// that can be run directly from the command line. + /// To make a script publicly available, + /// list it under the executables field. + /// Entries are listed as key/value pairs: + /// + /// ```yaml + /// : + /// ``` + /// + /// For example, the following pubspec entry lists two scripts: + /// + /// ```yaml + /// executables: + /// slidy: main + /// fvm: + /// ``` + /// + /// Once the package is activated using pub global activate, + /// typing `slidy` executes `bin/main.dart`. + /// Typing `fvm` executes `bin/fvm.dart`. + /// If you don’t specify the value, it is inferred from the key. + /// + /// For more information, see pub global. + static const Map executables = { + 'replace': r'', + 'pm': r'', + }; + + /// Source data from pubspec.yaml + static const Map source = { + 'name': name, + 'description': description, + 'repository': repository, + 'issue_tracker': issueTracker, + 'homepage': homepage, + 'documentation': documentation, + 'publish_to': publishTo, + 'version': version, + 'funding': funding, + 'false_secrets': falseSecrets, + 'screenshots': screenshots, + 'topics': topics, + 'platforms': platforms, + 'environment': environment, + 'dependencies': dependencies, + 'dev_dependencies': devDependencies, + 'dependency_overrides': dependencyOverrides, + 'executables': { + 'replace': r'', + 'pm': r'', + }, + }; + +} diff --git a/pubspec.yaml b/pubspec.yaml index 4b7630f..ba0babb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: replace -version: 1.1.0 +version: 1.2.0 description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace @@ -16,7 +16,9 @@ dependencies: yaml: ^3.1.3 dev_dependencies: + build_runner: ^2.13.1 path: ^1.9.1 + pubspec_generator: ^5.0.1 test: ^1.31.0 executables: diff --git a/test/fixtures/pm/remove_basic/pubspec.yaml b/test/fixtures/pm/remove_basic/pubspec.yaml new file mode 100644 index 0000000..b218390 --- /dev/null +++ b/test/fixtures/pm/remove_basic/pubspec.yaml @@ -0,0 +1,15 @@ +name: remove_basic_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: ^1.9.0 + collection: + hosted: https://pub.dev + version: ^1.19.0 + http_parser: + git: + url: https://github.com/dart-lang/http_parser.git + ref: master +dev_dependencies: + lints: ^2.0.0 diff --git a/test/pm_test.dart b/test/pm_test.dart index dc97b2e..9556711 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -17,6 +17,30 @@ void main() { }); group('pm CLI', () { + test('--version prints pm package version', () async { + final workDir = _copyFixture('basic', scratchRoot); + + final result = await _runPm( + ['--version'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString().trim(), _readRootPackageVersion()); + }); + + test('-v prints pm package version', () async { + final workDir = _copyFixture('basic', scratchRoot); + + final result = await _runPm( + ['-v'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString().trim(), _readRootPackageVersion()); + }); + test('set-sdk updates environment sdk constraint in current pubspec', () async { final workDir = _copyFixture('sdk_basic', scratchRoot); @@ -108,6 +132,41 @@ void main() { expect(content, contains('version: 1.9.1')); }); + test('remove removes multiple dependencies across declaration styles', + () async { + final workDir = _copyFixture('remove_basic', scratchRoot); + + final result = await _runPm( + ['remove', 'path', 'collection', 'http_parser'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + await _assertPubGetParses(workDir.path); + + final content = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + expect(_containsDependencyKey(content, 'path'), isFalse); + expect(_containsDependencyKey(content, 'collection'), isFalse); + expect(_containsDependencyKey(content, 'http_parser'), isFalse); + expect(_containsDependencyKey(content, 'lints'), isTrue); + }); + + test('remove requires at least one package name', () async { + final workDir = _copyFixture('remove_basic', scratchRoot); + + final result = await _runPm( + ['remove'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 64); + expect( + result.stderr.toString(), + contains('requires at least 1 argument: [dependency ...]'), + ); + }); + test('raise-min updates root fixture during recursive scan', () async { final workDir = _copyFixture('recursive', scratchRoot); @@ -453,6 +512,17 @@ Future _runPm( Directory get _repoRoot => Directory.current; +String _readRootPackageVersion() { + final content = + File(p.join(_repoRoot.path, 'pubspec.yaml')).readAsStringSync(); + final match = + RegExp(r'^version:\s*(\S+)\s*$', multiLine: true).firstMatch(content); + if (match == null) { + throw StateError('Could not read version from root pubspec.yaml'); + } + return match.group(1)!; +} + bool _hasConstraint(String content, String key, String constraint) { return content.contains("$key: $constraint") || content.contains("$key: '$constraint'") || @@ -465,6 +535,11 @@ bool _hasSdkConstraint(String content, String constraint) { content.contains('sdk: "$constraint"'); } +bool _containsDependencyKey(String content, String key) { + final matcher = RegExp('^\\s{2}${RegExp.escape(key)}:', multiLine: true); + return matcher.hasMatch(content); +} + void _expectUpdateOutput(String stdout) { expect( stdout, From 1a5a531fcd441922c8b51f07a92ed2e422a8c756 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:04:24 -0600 Subject: [PATCH 13/26] Build binaries --- .github/workflows/publish.yml | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5aca50c..199c872 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,50 @@ permissions: pull-requests: write jobs: + build-and-release: + name: Build binaries and create release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: dart-lang/setup-dart@v1 + + - run: dart pub get + + - run: dart run build_runner build --delete-conflicting-outputs + + - name: Compile for Linux + run: | + dart compile exe bin/replace.dart -o replace-linux --target-os=linux + dart compile exe bin/pm.dart -o pm-linux --target-os=linux + + - name: Compile for macOS (Apple Silicon) + run: | + dart compile exe bin/replace.dart -o replace-macos-arm64 --target-os=macos --target-arch=arm64 + dart compile exe bin/pm.dart -o pm-macos-arm64 --target-os=macos --target-arch=arm64 + + - name: Compile for macOS (Intel) + run: | + dart compile exe bin/replace.dart -o replace-macos-x64 --target-os=macos --target-arch=x64 + dart compile exe bin/pm.dart -o pm-macos-x64 --target-os=macos --target-arch=x64 + + - name: Compile for Windows + run: | + dart compile exe bin/replace.dart -o replace.exe --target-os=windows + dart compile exe bin/pm.dart -o pm.exe --target-os=windows + + - uses: softprops/action-gh-release@v2 + with: + files: | + replace-linux + replace-macos-arm64 + replace-macos-x64 + replace.exe + pm-linux + pm-macos-arm64 + pm-macos-x64 + pm.exe + publish: name: Publish to pub.dev runs-on: ubuntu-latest From 37c65ce2e517f8243fa08d518df5a3b0a3e30f16 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:16:15 -0600 Subject: [PATCH 14/26] Bump version to 1.2.1 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ba0babb..a7a1f4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: replace -version: 1.2.0 +version: 1.2.1 description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace From 87adfeac6adb582988cfcead6fd3cf91de9e5e6e Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:17:39 -0600 Subject: [PATCH 15/26] format --- lib/src/pubspec.yaml.g.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index 78776be..5ce94c0 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -164,7 +164,8 @@ sealed class Pubspec { /// Think of the description as the sales pitch for your package. /// Users see it when they [browse for packages](https://pub.dev/packages). /// The description is plain text: no markdown or HTML. - static const String description = r'An easy to use cross-platform regex replace command line util.'; + static const String description = + r'An easy to use cross-platform regex replace command line util.'; /// Homepage /// @@ -484,5 +485,4 @@ sealed class Pubspec { 'pm': r'', }, }; - } From 6aab75d343e4c1af15e9e3b3306472de0edf5c84 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:22:17 -0600 Subject: [PATCH 16/26] fix mac binary build Co-authored-by: Copilot --- .github/workflows/publish.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 199c872..f3b99c3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: tags: - '[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: permissions: contents: write @@ -30,8 +31,7 @@ jobs: - name: Compile for macOS (Apple Silicon) run: | - dart compile exe bin/replace.dart -o replace-macos-arm64 --target-os=macos --target-arch=arm64 - dart compile exe bin/pm.dart -o pm-macos-arm64 --target-os=macos --target-arch=arm64 + dart compile exe bin/replace.dart -o replace-mac --target-os=macos - name: Compile for macOS (Intel) run: | @@ -43,7 +43,8 @@ jobs: dart compile exe bin/replace.dart -o replace.exe --target-os=windows dart compile exe bin/pm.dart -o pm.exe --target-os=windows - - uses: softprops/action-gh-release@v2 + - if: github.event_name != 'workflow_dispatch' + uses: softprops/action-gh-release@v2 with: files: | replace-linux From ea28ffe19498ae7b9d0c94dc1ae88d7139f2ab58 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:23:46 -0600 Subject: [PATCH 17/26] commit more skipping Co-authored-by: Copilot --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dbb0675..470ec14 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,6 +58,7 @@ jobs: publish: name: Publish to pub.dev + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 From 99af7b64f3bdb41aa7ece56a57517cd1852de31c Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:28:24 -0600 Subject: [PATCH 18/26] onyl build for linux Co-authored-by: Copilot --- .github/workflows/publish.yml | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 470ec14..745b14d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,35 +26,15 @@ jobs: - name: Compile for Linux run: | - dart compile exe bin/replace.dart -o replace-linux --target-os=linux - dart compile exe bin/pm.dart -o pm-linux --target-os=linux - - - name: Compile for macOS (Apple Silicon) - run: | - dart compile exe bin/replace.dart -o replace-mac --target-os=macos - - - name: Compile for macOS (Intel) - run: | - dart compile exe bin/replace.dart -o replace-macos-x64 --target-os=macos --target-arch=x64 - dart compile exe bin/pm.dart -o pm-macos-x64 --target-os=macos --target-arch=x64 - - - name: Compile for Windows - run: | - dart compile exe bin/replace.dart -o replace.exe --target-os=windows - dart compile exe bin/pm.dart -o pm.exe --target-os=windows + dart compile exe bin/replace.dart -o replace --target-os=linux + dart compile exe bin/pm.dart -o pm --target-os=linux - if: github.event_name != 'workflow_dispatch' uses: softprops/action-gh-release@v2 with: files: | - replace-linux - replace-macos-arm64 - replace-macos-x64 - replace.exe - pm-linux - pm-macos-arm64 - pm-macos-x64 - pm.exe + replace + pm publish: name: Publish to pub.dev From 719e79a8394557623b8c9202a11f84de092bfa95 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Fri, 1 May 2026 14:33:02 -0600 Subject: [PATCH 19/26] update version to 1.2.2 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index a7a1f4d..11404b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: replace -version: 1.2.1 +version: 1.2.2 description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace From 024c181f695f9d9ed20c4f3c826065801c4b63fc Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 16:38:01 -0600 Subject: [PATCH 20/26] Add pm asdf/pub-get workflow improvements --- CHANGELOG.md | 15 +- README.md | 24 ++- bin/pm.dart | 311 +++++++++++++++++++++++++++++++++--- lib/src/pubspec.yaml.g.dart | 24 +-- pubspec.yaml | 2 +- test/pm_test.dart | 280 +++++++++++++++++++++++++++++++- 6 files changed, 617 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2c093..b514f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [1.2.3] - 2026-05-26 +### Added +- Added `pm set-asdf-dart` to read `environment.sdk` from `pubspec.yaml` and run `asdf set dart` for Dart 2 (`2.19.6`) or Dart 3. +- Added `--dart-3-version` to `pm set-asdf-dart` so the Dart 3 version can be overridden (default: `3.11.6`). +- Added global `--pub-get` to run `dart pub get` in directories where a command modified `pubspec.yaml`. + +### Updated +- Updated `pm` tests to cover `set-asdf-dart` behavior for Dart 2, Dart 3, and custom `--dart-3-version` values. +- Updated README command docs and examples for `set-asdf-dart` and `--dart-3-version`. +- Updated `pm` tests and docs for `--pub-get` behavior on changed and unchanged pubspecs. +- Updated `set-asdf-dart` to support recursive mode (`-r`) across discovered `pubspec.yaml` files. +- Updated `tighten -r` to use each pubspec directory's `pubspec.lock` by default. + ## [1.2.0] - 2026-04-13 ### Added - Added `pm remove` to remove one or more packages from `dependencies` and `dev_dependencies`. @@ -41,4 +54,4 @@ ### Added - Initial Version -[1.0.0]: https://github.com/robrbecker/replace/releases/tag/1.0.0 \ No newline at end of file +[1.0.0]: https://github.com/robrbecker/replace/releases/tag/1.0.0 diff --git a/README.md b/README.md index 5a25864..f5b777c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Global options: - `-r, --recursive` Recurse through subdirectories and process all pubspec.yaml files - `--fail-on-parse-error` Exit with non-zero if any pubspec.yaml cannot be parsed - `--[no-]tighten` Tighten is enabled by default. Use `--no-tighten` to keep explicit range output. +- `--pub-get` Run `dart pub get` in each directory where `pubspec.yaml` was modified by the command Commands: @@ -99,6 +100,7 @@ Commands: - `raise-max` Raise the maximum bound (exclusive) of a version range - `lower-max` Lower the maximum bound (exclusive) of a version range - `set-sdk` Set `environment.sdk` constraint exactly as provided +- `set-asdf-dart` Read `environment.sdk` and run `asdf set dart ` (Dart 3, default `3.11.6`, overridable with `--dart-3-version`) or `asdf set dart 2.19.6` (Dart 2) - `raise-min-sdk` Raise the minimum `environment.sdk` bound (inclusive) - `raise-max-sdk` Raise the maximum `environment.sdk` bound (exclusive) - `tighten` Raise all dependency minimums to resolved versions from `pubspec.lock` (or a provided lockfile path) @@ -110,10 +112,12 @@ Notes: - SDK commands update `environment.sdk` only - `set` accepts any valid Dart version constraint, for example `^1.9.0` or `'>=1.9.0 <2.0.0'` - `set-sdk` accepts any valid Dart SDK constraint, for example `'>=3.3.0 <4.0.0'` +- `set-asdf-dart` uses `environment.sdk` from the current `pubspec.yaml` (or all discovered pubspec files with `-r`) and accepts optional `--dart-3-version` (default `3.11.6`) - `raise-min`, `raise-max`, and `lower-max` expect a specific semantic version, for example `1.9.1` - `raise-min-sdk` and `raise-max-sdk` expect a specific semantic version, for example `3.4.0` - Tightening is enabled by default and only rewrites when the updated range is exactly equivalent to a caret constraint -- `tighten` reads `pubspec.lock` in the current directory by default and applies the equivalent of `raise-min --tighten` for each locked package +- `--pub-get` only runs in directories where a command actually changed `pubspec.yaml` +- `tighten` reads `pubspec.lock` in the current directory by default (or each pubspec directory when used with `-r`) and applies the equivalent of `raise-min --tighten` for each locked package - `'>=3.0.0 <4.0.0'` becomes `^3.0.0` - `'>=0.18.2 <0.19.0'` becomes `^0.18.2` - `'>=1.2.3 <4.0.0'` stays as a range (not equivalent to a caret constraint) @@ -216,6 +220,12 @@ Lower max version and fail if any pubspec is malformed: pm lower-max path 2.5.0 -r --fail-on-parse-error ``` +Raise minimum and run `dart pub get` for each modified pubspec directory: + +```sh +pm raise-min path 1.9.1 -r --pub-get +``` + Remove multiple dependencies in a single command: ```sh @@ -250,6 +260,18 @@ Set SDK constraint: pm set-sdk '>=3.3.0 <4.0.0' ``` +Set local asdf Dart version from SDK constraint: + +```sh +pm set-asdf-dart +``` + +Set local asdf Dart version with an overridden Dart 3 target: + +```sh +pm set-asdf-dart --dart-3-version 3.12.1 +``` + Raise SDK minimum recursively across a monorepo: ```sh diff --git a/bin/pm.dart b/bin/pm.dart index 465c67d..f955be2 100644 --- a/bin/pm.dart +++ b/bin/pm.dart @@ -14,9 +14,16 @@ const _commandRaiseMin = 'raise-min'; const _commandRaiseMinSdk = 'raise-min-sdk'; const _commandRemove = 'remove'; const _commandSet = 'set'; +const _commandSetAsdfDart = 'set-asdf-dart'; const _commandSetSdk = 'set-sdk'; const _commandTighten = 'tighten'; +const _dart2Version = '2.19.6'; +const _dart3Version = '3.11.6'; +const _asdfExecutableEnv = 'PM_ASDF_BIN'; +const _dartExecutableEnv = 'PM_DART_BIN'; +const _dart3VersionOption = 'dart-3-version'; + const _usageHeader = 'Usage: dart run pm [arguments]'; Future main(List args) async { @@ -65,6 +72,8 @@ Future _run(List args) async { return 64; } + final runPubGet = results['pub-get'] as bool; + if (commandName == _commandTighten) { final rest = command.rest; if (rest.length > 1) { @@ -78,37 +87,52 @@ Future _run(List args) async { final failOnParseError = results['fail-on-parse-error'] as bool; final recursive = results['recursive'] as bool; - final lockfilePath = rest.isEmpty ? 'pubspec.lock' : rest.single.trim(); - if (lockfilePath.isEmpty) { + final requestedLockfilePath = rest.isEmpty ? null : rest.single.trim(); + if (requestedLockfilePath != null && requestedLockfilePath.isEmpty) { stderr.writeln('Lockfile path must not be empty.'); return 64; } - final lockfile = File(lockfilePath); - if (!lockfile.existsSync()) { - stderr.writeln('Lockfile not found: ${lockfile.path}'); - return 64; - } - - Map lockedVersions; - try { - lockedVersions = _readLockedDependencyVersions(lockfile); - } on FormatException catch (e) { - stderr.writeln('Unable to parse ${lockfile.path}: ${e.message}'); - return 64; - } - - if (lockedVersions.isEmpty) { - return 0; - } - final pubspecFiles = _findPubspecFiles(recursive: recursive); if (pubspecFiles.isEmpty) { return 0; } var hadParseError = false; + final changedPubspecDirectories = {}; + final lockedVersionsCache = >{}; for (final path in pubspecFiles) { + final pubspecDirectory = File(path).parent; + final lockfilePath = requestedLockfilePath == null + ? '${pubspecDirectory.path}${Platform.pathSeparator}pubspec.lock' + : requestedLockfilePath; + final lockfile = File(lockfilePath); + if (!lockfile.existsSync()) { + if (recursive && requestedLockfilePath == null) { + continue; + } + stderr.writeln('Lockfile not found: ${lockfile.path}'); + return 64; + } + + final lockfileCacheKey = lockfile.absolute.path; + Map lockedVersions; + if (lockedVersionsCache.containsKey(lockfileCacheKey)) { + lockedVersions = lockedVersionsCache[lockfileCacheKey]!; + } else { + try { + lockedVersions = _readLockedDependencyVersions(lockfile); + } on FormatException catch (e) { + stderr.writeln('Unable to parse ${lockfile.path}: ${e.message}'); + return 64; + } + lockedVersionsCache[lockfileCacheKey] = lockedVersions; + } + + if (lockedVersions.isEmpty) { + continue; + } + PubSpec pubspec; try { pubspec = PubSpec.loadFromPath(path); @@ -155,8 +179,9 @@ Future _run(List args) async { } pubspec.saveTo(path); + changedPubspecDirectories.add(pubspecDirectory.path); for (final message in updateMessages) { - stdout.writeln(message); + stdout.writeln('$path: $message'); } } @@ -164,6 +189,14 @@ Future _run(List args) async { return 1; } + final pubGetExitCode = await _runPubGetForDirectories( + enabled: runPubGet, + directories: changedPubspecDirectories, + ); + if (pubGetExitCode != 0) { + return pubGetExitCode; + } + return 0; } @@ -197,6 +230,7 @@ Future _run(List args) async { } var hadParseError = false; + final changedPubspecDirectories = {}; for (final path in pubspecFiles) { PubSpec pubspec; try { @@ -223,8 +257,9 @@ Future _run(List args) async { } pubspec.saveTo(path); + changedPubspecDirectories.add(File(path).parent.path); for (final message in updateMessages) { - stdout.writeln(message); + stdout.writeln('$path: $message'); } } @@ -232,6 +267,151 @@ Future _run(List args) async { return 1; } + final pubGetExitCode = await _runPubGetForDirectories( + enabled: runPubGet, + directories: changedPubspecDirectories, + ); + if (pubGetExitCode != 0) { + return pubGetExitCode; + } + + return 0; + } + + if (commandName == _commandSetAsdfDart) { + final rest = command.rest; + if (rest.isNotEmpty) { + stderr.writeln('Command "$commandName" does not accept arguments.'); + stderr.writeln(''); + _printUsage(parser); + return 64; + } + + final requestedDart3Version = + (command[_dart3VersionOption] as String).trim(); + if (requestedDart3Version.isEmpty) { + stderr.writeln('Option --$_dart3VersionOption must not be empty.'); + return 64; + } + + final semver.Version dart2Probe; + final semver.Version dart3Probe; + try { + dart2Probe = semver.Version.parse(_dart2Version); + dart3Probe = semver.Version.parse(requestedDart3Version); + } on FormatException catch (e) { + stderr.writeln( + 'Invalid version for --$_dart3VersionOption "$requestedDart3Version": ${e.message}', + ); + return 64; + } + + final recursive = results['recursive'] as bool; + final failOnParseError = results['fail-on-parse-error'] as bool; + final pubspecFiles = _findPubspecFiles(recursive: recursive); + if (pubspecFiles.isEmpty) { + if (recursive) { + return 0; + } + final pubspecPath = + '${Directory.current.path}${Platform.pathSeparator}pubspec.yaml'; + stderr.writeln('pubspec.yaml not found: $pubspecPath'); + return 64; + } + + final asdfExecutable = Platform.environment[_asdfExecutableEnv] ?? 'asdf'; + var hadParseError = false; + for (final pubspecPath in pubspecFiles) { + final String sdkConstraintText; + try { + final pubspec = PubSpec.loadFromPath(pubspecPath); + sdkConstraintText = pubspec.environment.sdk.trim(); + } catch (e) { + hadParseError = true; + stderr.writeln('Unable to parse $pubspecPath: $e'); + if (failOnParseError) { + return 1; + } + continue; + } + + if (sdkConstraintText.isEmpty) { + hadParseError = true; + stderr.writeln('$pubspecPath environment.sdk must not be empty.'); + if (failOnParseError) { + return 1; + } + continue; + } + + final semver.VersionConstraint sdkConstraint; + try { + sdkConstraint = semver.VersionConstraint.parse( + _stripMatchingQuotes(sdkConstraintText), + ); + } on FormatException catch (e) { + hadParseError = true; + stderr.writeln( + 'Invalid SDK constraint "$sdkConstraintText" in $pubspecPath: ${e.message}', + ); + if (failOnParseError) { + return 1; + } + continue; + } + + final supportsDart2 = sdkConstraint.allows(dart2Probe); + final supportsDart3 = sdkConstraint.allows(dart3Probe); + + late String dartVersionToSet; + if (!supportsDart2 && supportsDart3) { + dartVersionToSet = requestedDart3Version; + } else if (supportsDart2) { + dartVersionToSet = _dart2Version; + } else { + hadParseError = true; + stderr.writeln( + 'Unable to determine Dart major from environment.sdk "$sdkConstraintText" in $pubspecPath.', + ); + if (failOnParseError) { + return 1; + } + continue; + } + + final targetDirectory = File(pubspecPath).parent.path; + final setResult = await Process.run( + asdfExecutable, + ['set', 'dart', dartVersionToSet], + workingDirectory: targetDirectory, + ); + + final commandOutput = (setResult.stdout as String).trim(); + if (commandOutput.isNotEmpty) { + stdout.writeln(commandOutput); + } + + final commandError = (setResult.stderr as String).trim(); + if (commandError.isNotEmpty) { + stderr.writeln(commandError); + } + + if (setResult.exitCode != 0) { + stderr.writeln( + 'Failed to run: asdf set dart $dartVersionToSet in $targetDirectory', + ); + return setResult.exitCode; + } + + stdout.writeln( + 'Set Dart to $dartVersionToSet in $targetDirectory based on SDK constraint $sdkConstraintText', + ); + } + + if (failOnParseError && hadParseError) { + return 1; + } + return 0; } @@ -304,6 +484,7 @@ Future _run(List args) async { } var hadParseError = false; + final changedPubspecDirectories = {}; for (final path in pubspecFiles) { PubSpec pubspec; try { @@ -356,8 +537,9 @@ Future _run(List args) async { } pubspec.saveTo(path); + changedPubspecDirectories.add(File(path).parent.path); for (final message in updateMessages) { - stdout.writeln(message); + stdout.writeln('$path: $message'); } } @@ -365,6 +547,72 @@ Future _run(List args) async { return 1; } + final pubGetExitCode = await _runPubGetForDirectories( + enabled: runPubGet, + directories: changedPubspecDirectories, + ); + if (pubGetExitCode != 0) { + return pubGetExitCode; + } + + return 0; +} + +Future _runPubGetForDirectories({ + required bool enabled, + required Set directories, +}) async { + if (!enabled || directories.isEmpty) { + return 0; + } + + final dartExecutable = Platform.environment[_dartExecutableEnv] ?? 'dart'; + final sortedDirectories = directories.toList()..sort(); + for (final directory in sortedDirectories) { + final versionResult = await Process.run( + dartExecutable, + ['--version'], + workingDirectory: directory, + ); + + final versionOutput = (versionResult.stdout as String).trim(); + if (versionOutput.isNotEmpty) { + stdout.writeln(versionOutput); + } + + final versionError = (versionResult.stderr as String).trim(); + if (versionError.isNotEmpty) { + stderr.writeln(versionError); + } + + if (versionResult.exitCode != 0) { + stderr.writeln('Failed to run: dart --version in $directory'); + return versionResult.exitCode; + } + + stdout.writeln('Running dart pub get in $directory'); + final result = await Process.run( + dartExecutable, + ['pub', 'get'], + workingDirectory: directory, + ); + + final commandOutput = (result.stdout as String).trim(); + if (commandOutput.isNotEmpty) { + stdout.writeln(commandOutput); + } + + final commandError = (result.stderr as String).trim(); + if (commandError.isNotEmpty) { + stderr.writeln(commandError); + } + + if (result.exitCode != 0) { + stderr.writeln('Failed to run: dart pub get in $directory'); + return result.exitCode; + } + } + return 0; } @@ -589,6 +837,13 @@ ArgParser _buildParser() { defaultsTo: true, help: 'When updating range constraints, rewrite equivalent ranges as caret constraints (for example >=3.0.0 <4.0.0 to ^3.0.0). Disable with --no-tighten.', + ) + ..addFlag( + 'pub-get', + aliases: ['pubget'], + negatable: false, + help: + 'Run dart pub get in each directory containing a pubspec.yaml that was modified by the command.', ); for (final command in [ @@ -599,12 +854,20 @@ ArgParser _buildParser() { _commandRaiseMin, _commandRaiseMinSdk, _commandSet, + _commandSetAsdfDart, _commandSetSdk, _commandTighten, ]) { parser.addCommand(command); } + parser.commands[_commandSetAsdfDart]?.addOption( + _dart3VersionOption, + defaultsTo: _dart3Version, + help: + 'Dart 3 version to set when environment.sdk requires Dart 3 (default: $_dart3Version).', + ); + return parser; } @@ -627,6 +890,8 @@ void _printUsage(ArgParser parser) { stdout.writeln('(sdk)'); stdout.writeln( ' set-sdk Set the SDK version constraint in environment.sdk.'); + stdout.writeln( + ' set-asdf-dart Set local asdf Dart version from environment.sdk (Dart 3 -> --dart-3-version, default 3.11.6; Dart 2 -> 2.19.6).'); stdout.writeln( ' raise-max-sdk Raise the maximum allowed SDK version (exclusive) in environment.sdk.'); stdout.writeln( diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index 5ce94c0..5fc138a 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -92,13 +92,13 @@ sealed class Pubspec { static const PubspecVersion version = ( /// Non-canonical string representation of the version as provided /// in the pubspec.yaml file. - representation: r'1.2.0', + representation: r'1.2.3', /// Returns a 'canonicalized' representation /// of the application version. /// This represents the version string in accordance with /// Semantic Versioning (SemVer) standards. - canonical: r'1.2.0', + canonical: r'1.2.3', /// MAJOR version when you make incompatible API changes. /// The major version number: 1 in "1.2.3". @@ -111,7 +111,7 @@ sealed class Pubspec { /// PATCH version when you make backward compatible bug fixes. /// The patch version number: 3 in "1.2.3". - patch: 0, + patch: 3, /// The pre-release identifier: "foo" in "1.2.3-foo". preRelease: [], @@ -123,13 +123,13 @@ sealed class Pubspec { /// Build date and time (UTC) static final DateTime timestamp = DateTime.utc( 2026, - 4, - 13, - 20, - 38, - 6, - 354, - 898, + 5, + 26, + 22, + 35, + 1, + 915, + 827, ); /// Name @@ -164,8 +164,7 @@ sealed class Pubspec { /// Think of the description as the sales pitch for your package. /// Users see it when they [browse for packages](https://pub.dev/packages). /// The description is plain text: no markdown or HTML. - static const String description = - r'An easy to use cross-platform regex replace command line util.'; + static const String description = r'An easy to use cross-platform regex replace command line util.'; /// Homepage /// @@ -485,4 +484,5 @@ sealed class Pubspec { 'pm': r'', }, }; + } diff --git a/pubspec.yaml b/pubspec.yaml index 11404b3..fa44109 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: replace -version: 1.2.2 +version: 1.2.3 description: An easy to use cross-platform regex replace command line util. repository: https://github.com/robrbecker/replace diff --git a/test/pm_test.dart b/test/pm_test.dart index 9556711..a573db3 100644 --- a/test/pm_test.dart +++ b/test/pm_test.dart @@ -112,6 +112,167 @@ void main() { expect(result.stderr.toString(), contains('Unable to parse')); }); + test('set-asdf-dart uses Dart 3 when sdk requires Dart 3', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: sdk_dart3_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +'''); + + final asdfBinary = _createFakeAsdfBin(workDir, expectedVersion: '3.11.6'); + final result = await _runPm( + ['set-asdf-dart'], + workingDirectory: workDir.path, + environment: { + 'PM_ASDF_BIN': asdfBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString(), contains('Set Dart to 3.11.6')); + expect(_readAsdfLog(workDir), equals('set dart 3.11.6\n')); + }); + + test('set-asdf-dart uses Dart 2 when sdk allows Dart 2', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: sdk_dart2_fixture +version: 0.0.1 +environment: + sdk: '>=2.19.0 <3.0.0' +'''); + + final asdfBinary = _createFakeAsdfBin(workDir, expectedVersion: '2.19.6'); + final result = await _runPm( + ['set-asdf-dart'], + workingDirectory: workDir.path, + environment: { + 'PM_ASDF_BIN': asdfBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString(), contains('Set Dart to 2.19.6')); + expect(_readAsdfLog(workDir), equals('set dart 2.19.6\n')); + }); + + test('set-asdf-dart allows overriding Dart 3 version', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: sdk_dart3_override_fixture +version: 0.0.1 +environment: + sdk: '>=3.12.0 <4.0.0' +'''); + + final asdfBinary = _createFakeAsdfBin(workDir, expectedVersion: '3.12.1'); + final result = await _runPm( + ['set-asdf-dart', '--dart-3-version', '3.12.1'], + workingDirectory: workDir.path, + environment: { + 'PM_ASDF_BIN': asdfBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString(), contains('Set Dart to 3.12.1')); + expect(_readAsdfLog(workDir), equals('set dart 3.12.1\n')); + }); + + test('set-asdf-dart supports recursive mode', () async { + final workDir = _writePubspecFixture(scratchRoot, ''' +name: sdk_recursive_set_asdf_root +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +'''); + final nestedDir = Directory(p.join(workDir.path, 'packages', 'nested')) + ..createSync(recursive: true); + File(p.join(nestedDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: sdk_recursive_set_asdf_nested +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +'''); + + final asdfBinary = _createCwdLoggingAsdfBin(workDir); + final result = await _runPm( + ['set-asdf-dart', '-r'], + workingDirectory: workDir.path, + environment: { + 'PM_ASDF_BIN': asdfBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + final calls = _readAsdfCwdLog(workDir) + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(); + expect(calls, hasLength(2)); + expect(calls.any((line) => line.endsWith(workDir.path)), isTrue); + expect(calls.any((line) => line.endsWith(nestedDir.path)), isTrue); + }); + + test('--pub-get runs dart pub get when pubspec is modified', () async { + final workDir = _copyFixture('basic', scratchRoot); + final dartBinary = _createFakeDartBin(workDir); + + final result = await _runPm( + ['set', 'path', '1.9.1', '--pub-get'], + workingDirectory: workDir.path, + environment: { + 'PM_DART_BIN': dartBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(result.stdout.toString(), contains('Dart SDK version: fake')); + final lines = _readDartPubGetLog(workDir) + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(); + expect(lines, hasLength(1)); + expect(p.basename(lines.single), equals(p.basename(workDir.path))); + }); + + test('--pub-get does not run dart pub get when pubspec is unchanged', + () async { + final workDir = _copyFixture('basic', scratchRoot); + final dartBinary = _createFakeDartBin(workDir); + + final result = await _runPm( + ['set', 'path', '^1.9.0', '--pub-get'], + workingDirectory: workDir.path, + environment: { + 'PM_DART_BIN': dartBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + expect(_readDartPubGetLog(workDir), isEmpty); + }); + + test('--pubget alias runs dart pub get when pubspec is modified', () async { + final workDir = _copyFixture('basic', scratchRoot); + final dartBinary = _createFakeDartBin(workDir); + + final result = await _runPm( + ['set', 'path', '1.9.1', '--pubget'], + workingDirectory: workDir.path, + environment: { + 'PM_DART_BIN': dartBinary.path, + }, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + final lines = _readDartPubGetLog(workDir) + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(); + expect(lines, hasLength(1)); + expect(p.basename(lines.single), equals(p.basename(workDir.path))); + }); + test('set updates plain and hosted dependency styles', () async { final workDir = _copyFixture('basic', scratchRoot); @@ -384,6 +545,41 @@ dependencies: expect(_hasConstraint(content, 'version', '>=0.2.3 <1.0.0'), isTrue); expect(_hasConstraint(content, 'test', '^1.25.15'), isTrue); }); + + test('tighten recursive uses each pubspec directory lockfile', () async { + final workDir = _copyFixture('tighten_basic', scratchRoot); + final nestedDir = Directory(p.join(workDir.path, 'packages', 'nested')) + ..createSync(recursive: true); + File(p.join(nestedDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: nested_tighten_fixture +version: 0.0.1 +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + path: '>=1.8.0 <2.0.0' +'''); + + _writeTightenLockfile(workDir, pathVersion: '1.9.1'); + _writeTightenLockfile( + nestedDir, + pathVersion: '1.10.0', + ); + + final result = await _runPm( + ['tighten', '-r'], + workingDirectory: workDir.path, + ); + + expect(result.exitCode, 0, reason: result.stderr.toString()); + + final rootContent = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + final nestedContent = + File(p.join(nestedDir.path, 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(rootContent, 'path', '^1.9.1'), isTrue); + expect(_hasConstraint(nestedContent, 'path', '^1.10.0'), isTrue); + }); }); } @@ -436,6 +632,7 @@ dependencies: void _writeTightenLockfile( Directory root, { String relativePath = 'pubspec.lock', + String pathVersion = '1.9.1', }) { File(p.join(root.path, relativePath)) ..createSync(recursive: true) @@ -466,7 +663,7 @@ packages: sha256: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "$pathVersion" test: dependency: "direct dev" description: @@ -498,6 +695,7 @@ void _copyDirectory(Directory source, Directory target) { Future _runPm( List args, { required String workingDirectory, + Map? environment, }) { final packageConfig = p.join(_repoRoot.path, '.dart_tool', 'package_config.json'); @@ -507,9 +705,89 @@ Future _runPm( 'dart', ['--packages=$packageConfig', script, ...args], workingDirectory: workingDirectory, + environment: environment, ); } +File _createFakeAsdfBin(Directory workDir, {required String expectedVersion}) { + final asdfScript = File(p.join(workDir.path, 'asdf_stub.sh')); + final logPath = p.join(workDir.path, '.asdf_calls.log'); + + asdfScript.writeAsStringSync(''' +#!/usr/bin/env bash +set -euo pipefail +printf '%s %s %s\n' "\${1:-}" "\${2:-}" "\${3:-}" >> "$logPath" +if [[ "\${1:-}" != "set" || "\${2:-}" != "dart" || "\${3:-}" != "$expectedVersion" ]]; then + echo "unexpected args: $expectedVersion expected" >&2 + exit 9 +fi +'''); + Process.runSync('chmod', ['+x', asdfScript.path]); + return asdfScript; +} + +String _readAsdfLog(Directory workDir) { + final logFile = File(p.join(workDir.path, '.asdf_calls.log')); + if (!logFile.existsSync()) { + return ''; + } + return logFile.readAsStringSync(); +} + +File _createCwdLoggingAsdfBin(Directory workDir) { + final asdfScript = File(p.join(workDir.path, 'asdf_cwd_stub.sh')); + final logPath = p.join(workDir.path, '.asdf_cwd_calls.log'); + + asdfScript.writeAsStringSync(''' +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" != "set" || "\${2:-}" != "dart" ]]; then + echo "unexpected args: \${1:-} \${2:-}" >&2 + exit 11 +fi +pwd >> "$logPath" +'''); + Process.runSync('chmod', ['+x', asdfScript.path]); + return asdfScript; +} + +String _readAsdfCwdLog(Directory workDir) { + final logFile = File(p.join(workDir.path, '.asdf_cwd_calls.log')); + if (!logFile.existsSync()) { + return ''; + } + return logFile.readAsStringSync(); +} + +File _createFakeDartBin(Directory workDir) { + final dartScript = File(p.join(workDir.path, 'dart_stub.sh')); + final logPath = p.join(workDir.path, '.dart_pub_get_calls.log'); + + dartScript.writeAsStringSync(''' +#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + echo "Dart SDK version: fake" + exit 0 +fi +if [[ "\${1:-}" != "pub" || "\${2:-}" != "get" ]]; then + echo "unexpected args: \${1:-} \${2:-}" >&2 + exit 10 +fi +pwd >> "$logPath" +'''); + Process.runSync('chmod', ['+x', dartScript.path]); + return dartScript; +} + +String _readDartPubGetLog(Directory workDir) { + final logFile = File(p.join(workDir.path, '.dart_pub_get_calls.log')); + if (!logFile.existsSync()) { + return ''; + } + return logFile.readAsStringSync(); +} + Directory get _repoRoot => Directory.current; String _readRootPackageVersion() { From 11ad8c018bd1109ac3435135c50ad3c5949f52cf Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 16:39:57 -0600 Subject: [PATCH 21/26] Enhance pm workflows for asdf, pub get, and recursive tighten behavior ## Summary - add `pm set-asdf-dart` to select and set Dart via `asdf` from `environment.sdk` - add `--dart-3-version` override (default `3.11.6`) for Dart 3 selection - add global `--pub-get`/`--pubget` to run `dart --version` then `dart pub get` for modified pubspec directories - include modified `pubspec.yaml` path in update output messages - make `set-asdf-dart` honor `-r` and `--fail-on-parse-error` - make `tighten -r` use each package directory's `pubspec.lock` by default - update docs/changelog and bump version metadata to `1.2.3` ## Testing - `test/pm_test.dart` (full suite) --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..21c3972 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +dart 3.11.6 From 3619e9fcf7b30676266f51b6d4dec34dad717d8f Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 16:43:57 -0600 Subject: [PATCH 22/26] format --- .tool-versions | 2 +- lib/src/pubspec.yaml.g.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.tool-versions b/.tool-versions index 21c3972..0c9a32a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -dart 3.11.6 +dart 3.12.1 diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index 5fc138a..b9775f3 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -164,7 +164,8 @@ sealed class Pubspec { /// Think of the description as the sales pitch for your package. /// Users see it when they [browse for packages](https://pub.dev/packages). /// The description is plain text: no markdown or HTML. - static const String description = r'An easy to use cross-platform regex replace command line util.'; + static const String description = + r'An easy to use cross-platform regex replace command line util.'; /// Homepage /// @@ -484,5 +485,4 @@ sealed class Pubspec { 'pm': r'', }, }; - } From b1d825fa0227f2f6d6d7f3211a5d4333770809f1 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 16:47:38 -0600 Subject: [PATCH 23/26] format and update description --- lib/src/pubspec.yaml.g.dart | 8 ++++---- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index b9775f3..60848d3 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -126,10 +126,10 @@ sealed class Pubspec { 5, 26, 22, - 35, - 1, - 915, - 827, + 45, + 39, + 772, + 949, ); /// Name diff --git a/pubspec.yaml b/pubspec.yaml index fa44109..32a24b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: replace version: 1.2.3 -description: An easy to use cross-platform regex replace command line util. +description: An easy to use cross-platform regex replace command line util and pm CLI to modify pubspec.yaml files. repository: https://github.com/robrbecker/replace environment: From 30f6cb6980a765414ccb7f7bae6aac694298c664 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 16:51:03 -0600 Subject: [PATCH 24/26] format again --- lib/src/pubspec.yaml.g.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index 60848d3..d769538 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -126,10 +126,10 @@ sealed class Pubspec { 5, 26, 22, - 45, - 39, - 772, - 949, + 50, + 38, + 418, + 139, ); /// Name @@ -165,7 +165,7 @@ sealed class Pubspec { /// Users see it when they [browse for packages](https://pub.dev/packages). /// The description is plain text: no markdown or HTML. static const String description = - r'An easy to use cross-platform regex replace command line util.'; + r'An easy to use cross-platform regex replace command line util and pm CLI to modify pubspec.yaml files.'; /// Homepage /// From 52730d41e9c718bd599263851d04fb4793ccf6e3 Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 17:02:44 -0600 Subject: [PATCH 25/26] ignore generated version file --- analysis_options.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index c065a24..858f034 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,7 @@ analyzer: exclude: - - test/fixtures/** \ No newline at end of file + - test/fixtures/** + +formatter: + exclude: + - "lib/src/pubspec.yaml.g.dart" \ No newline at end of file From 0125bcd6cf49ae2e923a77bccf37db47a06e709b Mon Sep 17 00:00:00 2001 From: Rob Becker Date: Tue, 26 May 2026 17:10:31 -0600 Subject: [PATCH 26/26] update CI --- .github/workflows/ci.yml | 12 ------------ .tool-versions | 1 - analysis_options.yaml | 4 ---- lib/src/pubspec.yaml.g.dart | 10 +++++----- 4 files changed, 5 insertions(+), 22 deletions(-) delete mode 100644 .tool-versions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8fb3af..71fa090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,18 +22,6 @@ jobs: - name: Install dependencies run: dart pub get - - name: Build generated files - run: dart run build_runner build --delete-conflicting-outputs - - - name: Verify generated files are up to date - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo "Generated files are not up to date." - echo "Run: dart run build_runner build --delete-conflicting-outputs" - git --no-pager diff - # exit 1 - fi - - name: Analyze run: dart analyze . diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 0c9a32a..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -dart 3.12.1 diff --git a/analysis_options.yaml b/analysis_options.yaml index 858f034..1206125 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,3 @@ analyzer: exclude: - test/fixtures/** - -formatter: - exclude: - - "lib/src/pubspec.yaml.g.dart" \ No newline at end of file diff --git a/lib/src/pubspec.yaml.g.dart b/lib/src/pubspec.yaml.g.dart index d769538..d572fca 100644 --- a/lib/src/pubspec.yaml.g.dart +++ b/lib/src/pubspec.yaml.g.dart @@ -125,11 +125,11 @@ sealed class Pubspec { 2026, 5, 26, - 22, - 50, - 38, - 418, - 139, + 23, + 8, + 40, + 314, + 946, ); /// Name