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..71fa090 --- /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@v6 + + - 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/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..745b14d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +name: Publish + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + +permissions: + contents: write + id-token: write + 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 --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 + pm + + publish: + name: Publish to pub.dev + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - 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..b514f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [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`. +- 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. +- 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 @@ -20,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/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..f5b777c 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,16 +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` -- Place the `replace` executable in your path +- `dart compile exe bin/pm.dart -o pm` +- Place the `replace` and `pm` executable in your path + +## How to use replace -### Running / Usage `replace ...` This means you can pass as many globs, directory names, or filenames @@ -45,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. @@ -62,3 +74,230 @@ 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] [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. +- `--pub-get` Run `dart pub get` in each directory where `pubspec.yaml` was modified by the command + +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 +- `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) + +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'` +- `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 +- `--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) + +### Examples + +Print pm version: + +```sh +pm --version +``` + +Short form: + +```sh +pm -v +``` + +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 +``` + +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 +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 +``` + +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 +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 +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 +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 +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/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1206125 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + exclude: + - test/fixtures/** diff --git a/bin/pm.dart b/bin/pm.dart new file mode 100644 index 0000000..f955be2 --- /dev/null +++ b/bin/pm.dart @@ -0,0 +1,1244 @@ +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'; + +const _commandLowerMax = 'lower-max'; +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 _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 { + final exitCode = await _run(args); + if (exitCode != 0) { + // ignore: avoid_print + stderr.writeln('pm 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 showVersion = results['version'] as bool; + if (showVersion) { + stdout.writeln(pmVersion); + 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 runPubGet = results['pub-get'] as bool; + + 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 requestedLockfilePath = rest.isEmpty ? null : rest.single.trim(); + if (requestedLockfilePath != null && requestedLockfilePath.isEmpty) { + stderr.writeln('Lockfile path must not be empty.'); + return 64; + } + + 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); + } 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); + changedPubspecDirectories.add(pubspecDirectory.path); + for (final message in updateMessages) { + stdout.writeln('$path: $message'); + } + } + + if (failOnParseError && hadParseError) { + return 1; + } + + final pubGetExitCode = await _runPubGetForDirectories( + enabled: runPubGet, + directories: changedPubspecDirectories, + ); + if (pubGetExitCode != 0) { + return pubGetExitCode; + } + + 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; + final changedPubspecDirectories = {}; + 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); + changedPubspecDirectories.add(File(path).parent.path); + for (final message in updateMessages) { + stdout.writeln('$path: $message'); + } + } + + if (failOnParseError && hadParseError) { + 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; + } + + 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; + final changedPubspecDirectories = {}; + 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); + changedPubspecDirectories.add(File(path).parent.path); + for (final message in updateMessages) { + stdout.writeln('$path: $message'); + } + } + + if (failOnParseError && hadParseError) { + 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; +} + +List _applyToDependencies( + 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, { + 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)]; +} + +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, + 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.', + ) + ..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 [ + _commandLowerMax, + _commandRemove, + _commandRaiseMax, + _commandRaiseMaxSdk, + _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; +} + +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(' remove Remove one or more dependencies by package name'); + 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( + ' 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( + ' 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) { + 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({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; + } + + 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('<'); +} + +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/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..d572fca --- /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.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.3', + + /// 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: 3, + + /// 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, + 5, + 26, + 23, + 8, + 40, + 314, + 946, + ); + + /// 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 and pm CLI to modify pubspec.yaml files.'; + + /// 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 1802e82..32a24b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,21 +1,26 @@ name: replace -version: 1.0.4 -description: An easy to use cross-platform regex replace command line util. +version: 1.2.3 +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: - 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 + args: ^2.7.0 + 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 + +dev_dependencies: + build_runner: ^2.13.1 + path: ^1.9.1 + pubspec_generator: ^5.0.1 + test: ^1.31.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 + pm: diff --git a/test/fixtures/pm/basic/pubspec.yaml b/test/fixtures/pm/basic/pubspec.yaml new file mode 100644 index 0000000..3231e53 --- /dev/null +++ b/test/fixtures/pm/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/pm/recursive/pubspec.yaml b/test/fixtures/pm/recursive/pubspec.yaml new file mode 100644 index 0000000..5a39e4a --- /dev/null +++ b/test/fixtures/pm/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/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/fixtures/pm/sdk_basic/pubspec.yaml b/test/fixtures/pm/sdk_basic/pubspec.yaml new file mode 100644 index 0000000..cc189c0 --- /dev/null +++ b/test/fixtures/pm/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/pm/sdk_recursive/pubspec.yaml b/test/fixtures/pm/sdk_recursive/pubspec.yaml new file mode 100644 index 0000000..ad29209 --- /dev/null +++ b/test/fixtures/pm/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/fixtures/pm/tighten_basic/pubspec.yaml b/test/fixtures/pm/tighten_basic/pubspec.yaml new file mode 100644 index 0000000..461ed86 --- /dev/null +++ b/test/fixtures/pm/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 new file mode 100644 index 0000000..a573db3 --- /dev/null +++ b/test/pm_test.dart @@ -0,0 +1,828 @@ +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('pm_test_'); + }); + + tearDown(() { + if (scratchRoot.existsSync()) { + scratchRoot.deleteSync(recursive: true); + } + }); + + 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); + + final result = await _runPm( + ['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 in recursive scan', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPm( + ['raise-min-sdk', '3.4.0', '-r', '--no-tighten'], + 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(); + + expect(_hasSdkConstraint(root, '>=3.4.0 <4.0.0'), isTrue); + }); + + test('raise-max-sdk updates upper sdk bound in recursive scan', () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPm( + ['raise-max-sdk', '5.0.0', '-r', '--no-tighten'], + 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(); + + expect(_hasSdkConstraint(root, '>=3.0.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 _runPm( + ['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-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); + + final result = await _runPm( + ['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('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); + + final result = await _runPm( + ['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()); + expect( + result.stdout.toString(), + contains("path ^1.9.0 updated to '>=1.9.1 <2.0.0'"), + ); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.1 <2.0.0'), isTrue); + }); + + test('raise-max then lower-max updates exclusive upper bound', () async { + final workDir = _copyFixture('recursive', scratchRoot); + + 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 _runPm( + ['lower-max', 'path', '2.5.0', '-r', '--no-tighten'], + 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.9.0 <3.0.0' updated to '>=1.9.0 <2.5.0'"), + ); + + final root = + File(p.join(workDir.path, 'pubspec.yaml')).readAsStringSync(); + + expect(_hasConstraint(root, 'path', '>=1.9.0 <2.5.0'), isTrue); + }); + + test('tighten defaults to true for dependency updates in recursive scan', + () async { + final workDir = _copyFixture('recursive', scratchRoot); + + final result = await _runPm( + ['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(); + + expect(_hasConstraint(root, 'path', '^1.9.1'), isTrue); + }); + + test('tighten defaults to true for sdk updates in recursive scan', + () async { + final workDir = _copyFixture('sdk_recursive', scratchRoot); + + final result = await _runPm( + ['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(); + + expect(_hasSdkConstraint(root, '^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 _runPm( + ['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 _runPm( + ['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 _runPm( + ['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 when recursive scan hits malformed pubspec', + () async { + final workDir = _copyFixture('recursive', scratchRoot); + _writeMalformedPubspec(workDir, 'packages/bad/pubspec.yaml'); + + final result = await _runPm( + ['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')); + }); + + test('tighten raises minimums from pubspec.lock by default', () async { + final workDir = _copyFixture('tighten_basic', scratchRoot); + _writeTightenLockfile(workDir); + + final result = await _runPm( + ['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'); + _writeTightenLockfile(workDir, relativePath: 'custom.lock'); + + final result = await _runPm( + ['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); + }); + + 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); + }); + }); +} + +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', 'pm', 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; +} + +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 _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 _writeTightenLockfile( + Directory root, { + String relativePath = 'pubspec.lock', + String pathVersion = '1.9.1', +}) { + 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: "$pathVersion" + 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); + 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 _runPm( + List args, { + required String workingDirectory, + Map? environment, +}) { + 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, + 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() { + 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'") || + content.contains('$key: "$constraint"'); +} + +bool _hasSdkConstraint(String content, String constraint) { + return content.contains('sdk: $constraint') || + content.contains("sdk: '$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, + matches(RegExp(r'^[^\s]+ .+ updated to .+$', multiLine: true)), + reason: + 'Expected update output lines in format: updated to ', + ); +}