dependency_services (#3304)

diff --git a/bin/dependency_services.dart b/bin/dependency_services.dart
new file mode 100644
index 0000000..3dabf5d
--- /dev/null
+++ b/bin/dependency_services.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// Support for automated upgrades.
+///
+/// For now this is not a finalized interface. Don't rely on this.
+library dependency_services;
+
+import 'dart:async';
+
+import 'package:args/args.dart';
+import 'package:args/command_runner.dart';
+import 'package:pub/src/command.dart';
+import 'package:pub/src/command/dependency_services.dart';
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:pub/src/io.dart';
+import 'package:pub/src/log.dart' as log;
+
+class _DependencyServicesCommandRunner extends CommandRunner<int>
+    implements PubTopLevel {
+  @override
+  String? get directory => argResults['directory'];
+
+  @override
+  bool get captureStackChains => argResults['verbose'];
+
+  @override
+  bool get trace => argResults['verbose'];
+
+  ArgResults? _argResults;
+
+  /// The top-level options parsed by the command runner.
+  @override
+  ArgResults get argResults {
+    final a = _argResults;
+    if (a == null) {
+      throw StateError(
+          'argResults cannot be used before Command.run is called.');
+    }
+    return a;
+  }
+
+  _DependencyServicesCommandRunner()
+      : super('dependency_services', 'Support for automatic upgrades',
+            usageLineLength: lineLength) {
+    argParser.addFlag('verbose',
+        abbr: 'v', negatable: false, help: 'Shortcut for "--verbosity=all".');
+    argParser.addOption(
+      'directory',
+      abbr: 'C',
+      help: 'Run the subcommand in the directory<dir>.',
+      defaultsTo: '.',
+      valueHelp: 'dir',
+    );
+
+    addCommand(DependencyServicesListCommand());
+    addCommand(DependencyServicesReportCommand());
+    addCommand(DependencyServicesApplyCommand());
+  }
+
+  @override
+  Future<int> run(Iterable<String> args) async {
+    try {
+      _argResults = parse(args);
+      return await runCommand(argResults) ?? exit_codes.SUCCESS;
+    } on UsageException catch (error) {
+      log.exception(error);
+      return exit_codes.USAGE;
+    }
+  }
+
+  @override
+  void printUsage() {
+    log.message(usage);
+  }
+
+  @override
+  log.Verbosity get verbosity => log.Verbosity.normal;
+}
+
+Future<void> main(List<String> arguments) async {
+  await flushThenExit(await _DependencyServicesCommandRunner().run(arguments));
+}
diff --git a/lib/src/command.dart b/lib/src/command.dart
index 556c04f..ee62392 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -13,7 +13,6 @@
 import 'package:path/path.dart' as p;
 
 import 'authentication/token_store.dart';
-import 'command_runner.dart';
 import 'entrypoint.dart';
 import 'exceptions.dart';
 import 'exit_codes.dart' as exit_codes;
@@ -127,7 +126,7 @@
   }
 
   PubTopLevel get _pubTopLevel =>
-      _pubEmbeddableCommand ?? runner as PubCommandRunner;
+      _pubEmbeddableCommand ?? runner as PubTopLevel;
 
   PubAnalytics? get analytics => _pubEmbeddableCommand?.analytics;
 
diff --git a/lib/src/command/dependency_services.dart b/lib/src/command/dependency_services.dart
new file mode 100644
index 0000000..47805bc
--- /dev/null
+++ b/lib/src/command/dependency_services.dart
@@ -0,0 +1,381 @@
+// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// This implements support for dependency-bot style automated upgrades.
+/// It is still work in progress - do not rely on the current output.
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:collection/collection.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:yaml/yaml.dart';
+import 'package:yaml_edit/yaml_edit.dart';
+
+import '../command.dart';
+import '../entrypoint.dart';
+import '../exceptions.dart';
+import '../io.dart';
+import '../log.dart' as log;
+import '../package.dart';
+import '../package_name.dart';
+import '../pubspec.dart';
+import '../pubspec_utils.dart';
+import '../solver.dart';
+import '../system_cache.dart';
+import '../utils.dart';
+
+class DependencyServicesReportCommand extends PubCommand {
+  @override
+  String get name => 'report';
+  @override
+  String get description =>
+      'Output a machine-digestible report of the upgrade options for each dependency.';
+  @override
+  String get argumentsDescription => '[options]';
+
+  @override
+  bool get takesArguments => false;
+
+  DependencyServicesReportCommand() {
+    argParser.addOption('directory',
+        abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir');
+  }
+
+  @override
+  Future<void> runProtected() async {
+    final compatiblePubspec = stripDependencyOverrides(entrypoint.root.pubspec);
+
+    final breakingPubspec = stripVersionUpperBounds(compatiblePubspec);
+
+    final compatiblePackagesResult =
+        await _tryResolve(compatiblePubspec, cache);
+
+    final breakingPackagesResult = await _tryResolve(breakingPubspec, cache);
+
+    // The packages in the current lockfile or resolved from current pubspec.yaml.
+    late Map<String, PackageId> currentPackages;
+
+    if (fileExists(entrypoint.lockFilePath)) {
+      currentPackages =
+          Map<String, PackageId>.from(entrypoint.lockFile.packages);
+    } else {
+      final resolution = await _tryResolve(entrypoint.root.pubspec, cache) ??
+          (throw DataException('Failed to resolve pubspec'));
+      currentPackages =
+          Map<String, PackageId>.fromIterable(resolution, key: (e) => e.name);
+    }
+    currentPackages.remove(entrypoint.root.name);
+
+    final dependencies = <Object>[];
+    final result = <String, Object>{'dependencies': dependencies};
+
+    Future<List<Object>> _computeUpgradeSet(
+      Pubspec rootPubspec,
+      PackageId? package, {
+      required UpgradeType upgradeType,
+    }) async {
+      if (package == null) return [];
+      final lockFile = entrypoint.lockFile;
+      final pubspec = upgradeType == UpgradeType.multiBreaking
+          ? stripVersionUpperBounds(rootPubspec)
+          : Pubspec(
+              rootPubspec.name,
+              dependencies: rootPubspec.dependencies.values,
+              devDependencies: rootPubspec.devDependencies.values,
+              sdkConstraints: rootPubspec.sdkConstraints,
+            );
+
+      final dependencySet = dependencySetOfPackage(pubspec, package);
+      if (dependencySet != null) {
+        // Force the version to be the new version.
+        dependencySet[package.name] =
+            package.toRange().withConstraint(package.toRange().constraint);
+      }
+
+      final resolution = await tryResolveVersions(
+        SolveType.get,
+        cache,
+        Package.inMemory(pubspec),
+        lockFile: lockFile,
+      );
+
+      // TODO(sigurdm): improve error messages.
+      if (resolution == null) {
+        throw DataException('Failed resolving');
+      }
+
+      return [
+        ...resolution.packages.where((r) {
+          if (r.name == rootPubspec.name) return false;
+          final originalVersion = currentPackages[r.name];
+          return originalVersion == null ||
+              r.version != originalVersion.version;
+        }).map((p) {
+          final depset = dependencySetOfPackage(rootPubspec, p);
+          final originalConstraint = depset?[p.name]?.constraint;
+          return {
+            'name': p.name,
+            'version': p.version.toString(),
+            'kind': _kindString(pubspec, p.name),
+            'constraint': originalConstraint == null
+                ? null
+                : upgradeType == UpgradeType.compatible
+                    ? originalConstraint.toString()
+                    : VersionConstraint.compatibleWith(p.version).toString(),
+            'previousVersion': currentPackages[p.name]?.version.toString(),
+            'previousConstraint': originalConstraint?.toString(),
+          };
+        }),
+        for (final oldPackageName in lockFile.packages.keys)
+          if (!resolution.packages
+              .any((newPackage) => newPackage.name == oldPackageName))
+            {
+              'name': oldPackageName,
+              'version': null,
+              'kind':
+                  'transitive', // Only transitive constraints can be removed.
+              'constraint': null,
+              'previousVersion':
+                  currentPackages[oldPackageName]?.version.toString(),
+              'previousConstraint': null,
+            },
+      ];
+    }
+
+    for (final package in currentPackages.values) {
+      final compatibleVersion = compatiblePackagesResult
+          ?.firstWhereOrNull((element) => element.name == package.name);
+      final multiBreakingVersion = breakingPackagesResult
+          ?.firstWhereOrNull((element) => element.name == package.name);
+      final singleBreakingPubspec = Pubspec(
+        compatiblePubspec.name,
+        version: compatiblePubspec.version,
+        sdkConstraints: compatiblePubspec.sdkConstraints,
+        dependencies: compatiblePubspec.dependencies.values,
+        devDependencies: compatiblePubspec.devDependencies.values,
+      );
+      final dependencySet =
+          dependencySetOfPackage(singleBreakingPubspec, package);
+      final kind = _kindString(compatiblePubspec, package.name);
+      PackageId? singleBreakingVersion;
+      if (dependencySet != null) {
+        dependencySet[package.name] = package
+            .toRange()
+            .withConstraint(stripUpperBound(package.toRange().constraint));
+        final singleBreakingPackagesResult =
+            await _tryResolve(singleBreakingPubspec, cache);
+        singleBreakingVersion = singleBreakingPackagesResult
+            ?.firstWhereOrNull((element) => element.name == package.name);
+      }
+      dependencies.add({
+        'name': package.name,
+        'version': package.version.toString(),
+        'kind': kind,
+        'latest': (await cache.getLatest(package))?.version.toString(),
+        'constraint':
+            _constraintOf(compatiblePubspec, package.name)?.toString(),
+        if (compatibleVersion != null)
+          'compatible': await _computeUpgradeSet(
+              compatiblePubspec, compatibleVersion,
+              upgradeType: UpgradeType.compatible),
+        'singleBreaking': kind != 'transitive' && singleBreakingVersion == null
+            ? []
+            : await _computeUpgradeSet(compatiblePubspec, singleBreakingVersion,
+                upgradeType: UpgradeType.singleBreaking),
+        'multiBreaking': kind != 'transitive' && multiBreakingVersion != null
+            ? await _computeUpgradeSet(compatiblePubspec, multiBreakingVersion,
+                upgradeType: UpgradeType.multiBreaking)
+            : [],
+      });
+    }
+    log.message(JsonEncoder.withIndent('  ').convert(result));
+  }
+}
+
+VersionConstraint? _constraintOf(Pubspec pubspec, String packageName) {
+  return (pubspec.dependencies[packageName] ??
+          pubspec.devDependencies[packageName])
+      ?.constraint;
+}
+
+String _kindString(Pubspec pubspec, String packageName) {
+  return pubspec.dependencies.containsKey(packageName)
+      ? 'direct'
+      : pubspec.devDependencies.containsKey(packageName)
+          ? 'dev'
+          : 'transitive';
+}
+
+/// Try to solve [pubspec] return [PackageId]s in the resolution or `null` if no
+/// resolution was found.
+Future<List<PackageId>?> _tryResolve(Pubspec pubspec, SystemCache cache) async {
+  final solveResult = await tryResolveVersions(
+    SolveType.upgrade,
+    cache,
+    Package.inMemory(pubspec),
+  );
+
+  return solveResult?.packages;
+}
+
+class DependencyServicesListCommand extends PubCommand {
+  @override
+  String get name => 'list';
+
+  @override
+  String get description =>
+      'Output a machine digestible listing of all dependencies';
+
+  @override
+  bool get takesArguments => false;
+
+  DependencyServicesListCommand() {
+    argParser.addOption('directory',
+        abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir');
+  }
+
+  @override
+  Future<void> runProtected() async {
+    final pubspec = entrypoint.root.pubspec;
+
+    final currentPackages = fileExists(entrypoint.lockFilePath)
+        ? entrypoint.lockFile.packages.values.toList()
+        : (await _tryResolve(pubspec, cache) ?? <PackageId>[]);
+
+    final dependencies = <Object>[];
+    final result = <String, Object>{'dependencies': dependencies};
+
+    for (final package in currentPackages) {
+      dependencies.add({
+        'name': package.name,
+        'version': package.version.toString(),
+        'kind': _kindString(pubspec, package.name),
+        'constraint': _constraintOf(pubspec, package.name).toString(),
+      });
+    }
+    log.message(JsonEncoder.withIndent('  ').convert(result));
+  }
+}
+
+enum UpgradeType {
+  /// Only upgrade pubspec.lock
+  compatible,
+
+  /// Unlock at most one dependency in pubspec.yaml
+  singleBreaking,
+
+  /// Unlock any dependencies in pubspec.yaml needed for getting the
+  /// latest resolvable version.
+  multiBreaking,
+}
+
+class DependencyServicesApplyCommand extends PubCommand {
+  @override
+  String get name => 'apply';
+
+  @override
+  String get description =>
+      'Output a machine digestible listing of all dependencies';
+
+  @override
+  bool get takesArguments => true;
+
+  DependencyServicesApplyCommand() {
+    argParser.addOption('directory',
+        abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir');
+  }
+
+  @override
+  Future<void> runProtected() async {
+    YamlEditor(readTextFile(entrypoint.pubspecPath));
+    final toApply = <_PackageVersion>[];
+    final input = json.decode(await utf8.decodeStream(stdin));
+    for (final change in input['dependencyChanges']) {
+      toApply.add(
+        _PackageVersion(
+          change['name'],
+          change['version'] != null ? Version.parse(change['version']) : null,
+          change['constraint'] != null
+              ? VersionConstraint.parse(change['constraint'])
+              : null,
+        ),
+      );
+    }
+
+    final pubspec = entrypoint.root.pubspec;
+    final pubspecEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
+    final lockFile = fileExists(entrypoint.lockFilePath)
+        ? readTextFile(entrypoint.lockFilePath)
+        : null;
+    final lockFileYaml = lockFile == null ? null : loadYaml(lockFile);
+    final lockFileEditor = lockFile == null ? null : YamlEditor(lockFile);
+    for (final p in toApply) {
+      final targetPackage = p.name;
+      final targetVersion = p.version;
+      final targetConstraint = p.constraint;
+
+      if (targetConstraint != null) {
+        final section = pubspec.dependencies[targetPackage] != null
+            ? 'dependencies'
+            : 'dev_dependencies';
+        pubspecEditor
+            .update([section, targetPackage], targetConstraint.toString());
+      } else if (targetVersion != null) {
+        final constraint = _constraintOf(pubspec, targetPackage);
+        if (constraint != null && !constraint.allows(targetVersion)) {
+          final section = pubspec.dependencies[targetPackage] != null
+              ? 'dependencies'
+              : 'dev_dependencies';
+          pubspecEditor.update([section, targetPackage],
+              VersionConstraint.compatibleWith(targetVersion).toString());
+        }
+      }
+      if (targetVersion != null &&
+          lockFileEditor != null &&
+          lockFileYaml['packages'].containsKey(targetPackage)) {
+        lockFileEditor.update(
+            ['packages', targetPackage, 'version'], targetVersion.toString());
+      }
+      if (targetVersion == null &&
+          lockFileEditor != null &&
+          !lockFileYaml['packages'].containsKey(targetPackage)) {
+        dataError(
+          'Trying to remove non-existing transitive dependency $targetPackage.',
+        );
+      }
+    }
+
+    if (pubspecEditor.edits.isNotEmpty) {
+      writeTextFile(entrypoint.pubspecPath, pubspecEditor.toString());
+    }
+    if (lockFileEditor != null && lockFileEditor.edits.isNotEmpty) {
+      writeTextFile(entrypoint.lockFilePath, lockFileEditor.toString());
+    }
+    await log.warningsOnlyUnlessTerminal(
+      () async {
+        // This will fail if the new configuration does not resolve.
+        await Entrypoint(directory, cache).acquireDependencies(SolveType.get,
+            analytics: null, generateDotPackages: false);
+      },
+    );
+    // Dummy message.
+    log.message(json.encode({'dependencies': []}));
+  }
+}
+
+class _PackageVersion {
+  String name;
+  Version? version;
+  VersionConstraint? constraint;
+  _PackageVersion(this.name, this.version, this.constraint);
+}
+
+Map<String, PackageRange>? dependencySetOfPackage(
+    Pubspec pubspec, PackageName package) {
+  return pubspec.dependencies.containsKey(package.name)
+      ? pubspec.dependencies
+      : pubspec.devDependencies.containsKey(package.name)
+          ? pubspec.devDependencies
+          : null;
+}
diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart
index 609b7ae..2941952 100644
--- a/lib/src/command/outdated.dart
+++ b/lib/src/command/outdated.dart
@@ -10,7 +10,6 @@
 import 'package:collection/collection.dart'
     show IterableExtension, IterableNullableExtension;
 import 'package:path/path.dart' as path;
-import 'package:pub_semver/pub_semver.dart';
 
 import '../command.dart';
 import '../command_runner.dart';
@@ -181,18 +180,23 @@
       PackageId? latest;
       // If not overridden in current resolution we can use this
       if (!entrypoint.root.pubspec.dependencyOverrides.containsKey(name)) {
-        latest ??= await _getLatest(current);
+        latest ??=
+            await cache.getLatest(current, allowPrereleases: prereleases);
       }
       // If present as a dependency or dev_dependency we use this
-      latest ??= await _getLatest(rootPubspec.dependencies[name]);
-      latest ??= await _getLatest(rootPubspec.devDependencies[name]);
+      latest ??= await cache.getLatest(rootPubspec.dependencies[name],
+          allowPrereleases: prereleases);
+      latest ??= await cache.getLatest(rootPubspec.devDependencies[name],
+          allowPrereleases: prereleases);
       // If not overridden and present in either upgradable or resolvable we
       // use this reference to find the latest
       if (!upgradablePubspec.dependencyOverrides.containsKey(name)) {
-        latest ??= await _getLatest(upgradable);
+        latest ??=
+            await cache.getLatest(upgradable, allowPrereleases: prereleases);
       }
       if (!resolvablePubspec.dependencyOverrides.containsKey(name)) {
-        latest ??= await _getLatest(resolvable);
+        latest ??=
+            await cache.getLatest(resolvable, allowPrereleases: prereleases);
       }
       // Otherwise, we might simply not have a latest, when a transitive
       // dependency is overridden the source can depend on which versions we
@@ -200,7 +204,8 @@
       // allow 3rd party pub servers, but other servers might. Hence, we choose
       // to fallback to using the overridden source for latest.
       if (latest == null) {
-        latest ??= await _getLatest(current ?? upgradable ?? resolvable);
+        latest ??= await cache.getLatest(current ?? upgradable ?? resolvable,
+            allowPrereleases: prereleases);
         latestIsOverridden = true;
       }
 
@@ -304,37 +309,6 @@
     return argResults['mode'] == 'null-safety';
   }();
 
-  /// Get the latest version of [package].
-  ///
-  /// Will include prereleases in the comparison if '--prereleases' was enabled
-  /// by the arguments.
-  ///
-  /// If [package] is a [PackageId] with a prerelease version and there are no
-  /// later stable version we return a prerelease version if it exists.
-  ///
-  /// Returns `null`, if unable to find the package.
-  Future<PackageId?> _getLatest(PackageName? package) async {
-    if (package == null) {
-      return null;
-    }
-    final ref = package.toRef();
-    final available = await cache.source(ref.source).getVersions(ref);
-    if (available.isEmpty) {
-      return null;
-    }
-
-    // TODO(sigurdm): Refactor this to share logic with report.dart.
-    available.sort(prereleases
-        ? (x, y) => x.version.compareTo(y.version)
-        : (x, y) => Version.prioritize(x.version, y.version));
-    if (package is PackageId &&
-        package.version.isPreRelease &&
-        package.version > available.last.version) {
-      available.sort((x, y) => x.version.compareTo(y.version));
-    }
-    return available.last;
-  }
-
   /// Retrieves the pubspec of package [name] in [version] from [source].
   ///
   /// Returns `null`, if given `null` as a convinience.
diff --git a/lib/src/io.dart b/lib/src/io.dart
index 280dcbd..2faf85f 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -1080,3 +1080,13 @@
     return null;
   }
 }();
+
+/// Escape [x] for users to copy-paste in bash.
+///
+/// If x is alphanumeric we leave it as is.
+///
+/// Otherwise, wrap with single quotation, and use '\'' to insert single quote.
+String escapeShellArgument(String x) =>
+    RegExp(r'^[a-zA-Z0-9-_=@.]+$').stringMatch(x) == null
+        ? "'${x.replaceAll(r'\', '\\').replaceAll("'", r"'\''")}'"
+        : x;
diff --git a/lib/src/pubspec_utils.dart b/lib/src/pubspec_utils.dart
index 251c8c9..ee904a7 100644
--- a/lib/src/pubspec_utils.dart
+++ b/lib/src/pubspec_utils.dart
@@ -4,7 +4,6 @@
 
 import 'dart:async';
 
-import 'package:meta/meta.dart';
 import 'package:pub_semver/pub_semver.dart';
 
 import 'package_name.dart';
@@ -148,7 +147,6 @@
 
 /// Removes the upper bound of [constraint]. If [constraint] is the
 /// empty version constraint, [VersionConstraint.empty] will be returned.
-@visibleForTesting
 VersionConstraint stripUpperBound(VersionConstraint constraint) {
   ArgumentError.checkNotNull(constraint, 'constraint');
 
diff --git a/lib/src/system_cache.dart b/lib/src/system_cache.dart
index dc6eb4a..1653f1e 100644
--- a/lib/src/system_cache.dart
+++ b/lib/src/system_cache.dart
@@ -5,6 +5,7 @@
 import 'dart:io';
 
 import 'package:path/path.dart' as p;
+import 'package:pub_semver/pub_semver.dart';
 
 import 'authentication/token_store.dart';
 import 'io.dart';
@@ -20,6 +21,7 @@
 import 'source/sdk.dart';
 import 'source/unknown.dart';
 import 'source_registry.dart';
+import 'utils.dart';
 
 /// The system-wide cache of downloaded packages.
 ///
@@ -146,4 +148,43 @@
     log.fine('Clean up system cache temp directory $tempDir.');
     if (dirExists(tempDir)) deleteEntry(tempDir);
   }
+
+  /// Get the latest version of [package].
+  ///
+  /// Will consider _prereleases_ if:
+  ///  * [allowPrereleases] is true, or,
+  ///  * [package] is a [PackageId] with a prerelease version, and no later prerelease exists.
+  ///
+  /// Returns `null`, if unable to find the package.
+  Future<PackageId?> getLatest(
+    PackageName? package, {
+    bool allowPrereleases = false,
+  }) async {
+    if (package == null) {
+      return null;
+    }
+    final ref = package.toRef();
+    // TODO: Pass some maxAge to getVersions
+    final available = await source(ref.source).getVersions(ref);
+    if (available.isEmpty) {
+      return null;
+    }
+
+    final latest = maxAll(
+      available.map((id) => id.version),
+      allowPrereleases ? Comparable.compare : Version.prioritize,
+    );
+
+    if (package is PackageId &&
+        package.version.isPreRelease &&
+        package.version > latest &&
+        !allowPrereleases) {
+      return getLatest(package, allowPrereleases: true);
+    }
+
+    // There should be exactly one entry in [available] matching [latest]
+    assert(available.where((id) => id.version == latest).length == 1);
+
+    return available.firstWhere((id) => id.version == latest);
+  }
 }
diff --git a/test/dependency_services/dependency_services_test.dart b/test/dependency_services/dependency_services_test.dart
new file mode 100644
index 0000000..4503e67
--- /dev/null
+++ b/test/dependency_services/dependency_services_test.dart
@@ -0,0 +1,254 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:pub/src/io.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../golden_file.dart';
+import '../test_pub.dart';
+
+void manifestAndLockfile(GoldenTestContext context) {
+  String catFile(String filename) {
+    final contents = filterUnstableLines(
+        File(p.join(d.sandbox, appPath, filename)).readAsLinesSync());
+
+    return '''
+\$ cat $filename
+${contents.join('\n')}''';
+  }
+
+  context.expectNextSection('''
+${catFile('pubspec.yaml')}
+${catFile('pubspec.lock')}
+''');
+}
+
+late final String snapshot;
+
+extension on GoldenTestContext {
+  /// Returns the stdout.
+  Future<String> runDependencyServices(List<String> args,
+      {String? stdin}) async {
+    final buffer = StringBuffer();
+    buffer.writeln('## Section ${args.join(' ')}');
+    final process = await Process.start(
+      Platform.resolvedExecutable,
+      [
+        snapshot,
+        ...args,
+      ],
+      environment: getPubTestEnvironment(),
+      workingDirectory: p.join(d.sandbox, appPath),
+    );
+    if (stdin != null) {
+      process.stdin.write(stdin);
+      await process.stdin.flush();
+      await process.stdin.close();
+    }
+    final outLines = outputLines(process.stdout);
+    final errLines = outputLines(process.stderr);
+    final exitCode = await process.exitCode;
+
+    final pipe = stdin == null ? '' : ' echo ${escapeShellArgument(stdin)} |';
+    buffer.writeln([
+      '\$$pipe dependency_services ${args.map(escapeShellArgument).join(' ')}',
+      ...await outLines,
+      ...(await errLines).map((e) => '[STDERR] $e'),
+      if (exitCode != 0) '[EXIT CODE] $exitCode',
+    ].join('\n'));
+
+    expectNextSection(buffer.toString());
+    return (await outLines).join('\n');
+  }
+}
+
+Future<Iterable<String>> outputLines(Stream<List<int>> stream) async {
+  final s = await utf8.decodeStream(stream);
+  if (s.isEmpty) return [];
+  return filterUnstableLines(s.split('\n'));
+}
+
+Future<void> listReportApply(
+  GoldenTestContext context,
+  List<_PackageVersion> upgrades, {
+  void Function(Map)? reportAssertions,
+}) async {
+  manifestAndLockfile(context);
+  await context.runDependencyServices(['list']);
+  final report = await context.runDependencyServices(['report']);
+  if (reportAssertions != null) {
+    reportAssertions(json.decode(report));
+  }
+  final input = json.encode({
+    'dependencyChanges': upgrades,
+  });
+
+  await context.runDependencyServices(['apply'], stdin: input);
+  manifestAndLockfile(context);
+}
+
+Future<void> main() async {
+  setUpAll(() async {
+    final tempDir = Directory.systemTemp.createTempSync();
+    snapshot = p.join(tempDir.path, 'dependency_services.dart.snapshot');
+    final r = Process.runSync(Platform.resolvedExecutable, [
+      '--snapshot=$snapshot',
+      p.join('bin', 'dependency_services.dart'),
+    ]);
+    expect(r.exitCode, 0, reason: r.stderr);
+  });
+
+  tearDownAll(() {
+    File(snapshot).parent.deleteSync(recursive: true);
+  });
+
+  testWithGolden('Removing transitive', (context) async {
+    (await servePackages())
+      ..serve('foo', '1.2.3', deps: {'transitive': '^1.0.0'})
+      ..serve('foo', '2.2.3')
+      ..serve('transitive', '1.0.0');
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+        },
+      })
+    ]).create();
+    await pubGet();
+    await listReportApply(context, [
+      _PackageVersion('foo', Version.parse('2.2.3')),
+      _PackageVersion('transitive', null)
+    ], reportAssertions: (report) {
+      expect(
+        findChangeVersion(report, 'singleBreaking', 'foo'),
+        '2.2.3',
+      );
+      expect(
+        findChangeVersion(report, 'singleBreaking', 'transitive'),
+        null,
+      );
+    });
+  });
+
+  testWithGolden('Compatible', (context) async {
+    final server = (await servePackages())
+      ..serve('foo', '1.2.3')
+      ..serve('foo', '2.2.3')
+      ..serve('bar', '1.2.3')
+      ..serve('bar', '2.2.3');
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+          'bar': '^1.0.0',
+        },
+      })
+    ]).create();
+    await pubGet();
+    server.serve('foo', '1.2.4');
+    await listReportApply(context, [
+      _PackageVersion('foo', Version.parse('1.2.4')),
+    ], reportAssertions: (report) {
+      expect(
+        findChangeVersion(report, 'compatible', 'foo'),
+        '1.2.4',
+      );
+    });
+  });
+
+  testWithGolden('Adding transitive', (context) async {
+    (await servePackages())
+      ..serve('foo', '1.2.3')
+      ..serve('foo', '2.2.3', deps: {'transitive': '^1.0.0'})
+      ..serve('transitive', '1.0.0');
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+        },
+      })
+    ]).create();
+    await pubGet();
+    await listReportApply(context, [
+      _PackageVersion('foo', Version.parse('2.2.3')),
+      _PackageVersion('transitive', Version.parse('1.0.0'))
+    ], reportAssertions: (report) {
+      expect(
+        findChangeVersion(report, 'singleBreaking', 'foo'),
+        '2.2.3',
+      );
+      expect(
+        findChangeVersion(report, 'singleBreaking', 'transitive'),
+        '1.0.0',
+      );
+    });
+  });
+
+  testWithGolden('multibreaking', (context) async {
+    final server = (await servePackages())
+      ..serve('foo', '1.0.0')
+      ..serve('bar', '1.0.0');
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+          'bar': '^1.0.0',
+        },
+      })
+    ]).create();
+    await pubGet();
+    server
+      ..serve('foo', '1.5.0') // compatible
+      ..serve('foo', '2.0.0') // single breaking
+      ..serve('foo', '3.0.0', deps: {'bar': '^2.0.0'}) // multi breaking
+      ..serve('foo', '3.0.1', deps: {'bar': '^2.0.0'})
+      ..serve('bar', '2.0.0', deps: {'foo': '^3.0.0'})
+      ..serve('transitive', '1.0.0');
+    await listReportApply(context, [
+      _PackageVersion('foo', Version.parse('3.0.1'),
+          constraint: VersionConstraint.parse('^3.0.0')),
+      _PackageVersion('bar', Version.parse('2.0.0'))
+    ], reportAssertions: (report) {
+      expect(
+        findChangeVersion(report, 'multiBreaking', 'foo'),
+        '3.0.1',
+      );
+      expect(
+        findChangeVersion(report, 'multiBreaking', 'bar'),
+        '2.0.0',
+      );
+    });
+  });
+}
+
+dynamic findChangeVersion(dynamic json, String updateType, String name) {
+  final dep = json['dependencies'].firstWhere((p) => p['name'] == 'foo');
+  return dep[updateType].firstWhere((p) => p['name'] == name)['version'];
+}
+
+class _PackageVersion {
+  String name;
+  Version? version;
+  VersionConstraint? constraint;
+  _PackageVersion(this.name, this.version, {this.constraint});
+
+  Map<String, Object?> toJson() => {
+        'name': name,
+        'version': version?.toString(),
+        if (constraint != null) 'constraint': constraint.toString()
+      };
+}
diff --git a/test/golden_file.dart b/test/golden_file.dart
index 7927776..6a88dd9 100644
--- a/test/golden_file.dart
+++ b/test/golden_file.dart
@@ -151,6 +151,7 @@
     List<String> args, {
     Map<String, String>? environment,
     String? workingDirectory,
+    String? stdin,
   }) async {
     // Create new section index number (before doing anything async)
     final sectionIndex = _nextSectionIndex++;
@@ -161,6 +162,7 @@
       s,
       environment: environment,
       workingDirectory: workingDirectory,
+      stdin: stdin,
     );
 
     _expectSection(sectionIndex, s.toString());
diff --git a/test/test_pub.dart b/test/test_pub.dart
index fbf63de..94e64fc 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -891,7 +891,8 @@
 /// A [StreamMatcher] that matches multiple lines of output.
 StreamMatcher emitsLines(String output) => emitsInOrder(output.split('\n'));
 
-Iterable<String> _filter(List<String> input) {
+/// Removes output from pub known to be unstable.
+Iterable<String> filterUnstableLines(List<String> input) {
   return input
       // Downloading order is not deterministic, so to avoid flakiness we filter
       // out these lines.
@@ -916,12 +917,18 @@
   StringBuffer buffer, {
   Map<String, String>? environment,
   String? workingDirectory,
+  String? stdin,
 }) async {
   final process = await startPub(
     args: args,
     environment: environment,
     workingDirectory: workingDirectory,
   );
+  if (stdin != null) {
+    process.stdin.write(stdin);
+    await process.stdin.flush();
+    await process.stdin.close();
+  }
   final exitCode = await process.exitCode;
 
   // TODO(jonasfj): Clean out temporary directory names from env vars...
@@ -933,11 +940,12 @@
   //       .map((e) => '\$ export ${e.key}=${e.value}')
   //       .join('\n'));
   // }
-  buffer.writeln(_filter([
-    '\$ pub ${args.join(' ')}',
+  final pipe = stdin == null ? '' : ' echo ${escapeShellArgument(stdin)} |';
+  buffer.writeln(filterUnstableLines([
+    '\$$pipe pub ${args.map(escapeShellArgument).join(' ')}',
     ...await process.stdout.rest.toList(),
   ]).join('\n'));
-  for (final line in _filter(await process.stderr.rest.toList())) {
+  for (final line in filterUnstableLines(await process.stderr.rest.toList())) {
     buffer.writeln('[STDERR] $line');
   }
   if (exitCode != 0) {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt
new file mode 100644
index 0000000..368a120
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt
@@ -0,0 +1,115 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.3"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section list
+$ dependency_services list
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "latest": "2.2.3",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "transitive",
+          "version": "1.0.0",
+          "kind": "transitive",
+          "constraint": null,
+          "previousVersion": null,
+          "previousConstraint": null
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "transitive",
+          "version": "1.0.0",
+          "kind": "transitive",
+          "constraint": null,
+          "previousVersion": null,
+          "previousConstraint": null
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section apply
+$ echo '{"dependencyChanges":[{"name":"foo","version":"2.2.3"},{"name":"transitive","version":"1.0.0"}]}' | dependency_services apply
+{"dependencies":[]}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":^2.2.3},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "2.2.3"
+  transitive:
+    dependency: transitive
+    description:
+      name: transitive
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt
new file mode 100644
index 0000000..4c3fd0b
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt
@@ -0,0 +1,149 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.3"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.3"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section list
+$ dependency_services list
+{
+  "dependencies": [
+    {
+      "name": "bar",
+      "version": "1.2.3",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    },
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "bar",
+      "version": "1.2.3",
+      "kind": "direct",
+      "latest": "2.2.3",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [
+        {
+          "name": "bar",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "bar",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        }
+      ]
+    },
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "latest": "2.2.3",
+      "constraint": "^1.0.0",
+      "compatible": [
+        {
+          "name": "foo",
+          "version": "1.2.4",
+          "kind": "direct",
+          "constraint": "^1.0.0",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        }
+      ],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section apply
+$ echo '{"dependencyChanges":[{"name":"foo","version":"1.2.4"}]}' | dependency_services apply
+{"dependencies":[]}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.3"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.4"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt
new file mode 100644
index 0000000..3a0133a
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt
@@ -0,0 +1,131 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.2.3"
+  transitive:
+    dependency: transitive
+    description:
+      name: transitive
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section list
+$ dependency_services list
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    },
+    {
+      "name": "transitive",
+      "version": "1.0.0",
+      "kind": "transitive",
+      "constraint": "null"
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.2.3",
+      "kind": "direct",
+      "latest": "2.2.3",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "transitive",
+          "version": null,
+          "kind": "transitive",
+          "constraint": null,
+          "previousVersion": "1.0.0",
+          "previousConstraint": null
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "2.2.3",
+          "kind": "direct",
+          "constraint": "^2.2.3",
+          "previousVersion": "1.2.3",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "transitive",
+          "version": null,
+          "kind": "transitive",
+          "constraint": null,
+          "previousVersion": "1.0.0",
+          "previousConstraint": null
+        }
+      ]
+    },
+    {
+      "name": "transitive",
+      "version": "1.0.0",
+      "kind": "transitive",
+      "latest": "1.0.0",
+      "constraint": null,
+      "compatible": [],
+      "singleBreaking": [],
+      "multiBreaking": []
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section apply
+$ echo '{"dependencyChanges":[{"name":"foo","version":"2.2.3"},{"name":"transitive","version":null}]}' | dependency_services apply
+{"dependencies":[]}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":^2.2.3},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "2.2.3"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt
new file mode 100644
index 0000000..1b3af24
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt
@@ -0,0 +1,156 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section list
+$ dependency_services list
+{
+  "dependencies": [
+    {
+      "name": "bar",
+      "version": "1.0.0",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    },
+    {
+      "name": "foo",
+      "version": "1.0.0",
+      "kind": "direct",
+      "constraint": "^1.0.0"
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "bar",
+      "version": "1.0.0",
+      "kind": "direct",
+      "latest": "2.0.0",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [],
+      "multiBreaking": [
+        {
+          "name": "bar",
+          "version": "2.0.0",
+          "kind": "direct",
+          "constraint": "^2.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "foo",
+          "version": "3.0.1",
+          "kind": "direct",
+          "constraint": "^3.0.1",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        }
+      ]
+    },
+    {
+      "name": "foo",
+      "version": "1.0.0",
+      "kind": "direct",
+      "latest": "3.0.1",
+      "constraint": "^1.0.0",
+      "compatible": [
+        {
+          "name": "foo",
+          "version": "1.5.0",
+          "kind": "direct",
+          "constraint": "^1.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        }
+      ],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "2.0.0",
+          "kind": "direct",
+          "constraint": "^2.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "3.0.1",
+          "kind": "direct",
+          "constraint": "^3.0.1",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        },
+        {
+          "name": "bar",
+          "version": "2.0.0",
+          "kind": "direct",
+          "constraint": "^2.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0"
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section apply
+$ echo '{"dependencyChanges":[{"name":"foo","version":"3.0.1","constraint":"^3.0.0"},{"name":"bar","version":"2.0.0"}]}' | dependency_services apply
+{"dependencies":[]}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":^3.0.0,"bar":^2.0.0},"environment":{"sdk":">=0.1.2 <1.0.0"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "2.0.0"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "3.0.1"
+sdks:
+  dart: ">=0.1.2 <1.0.0"
diff --git a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
index 237b3b5..d80d984 100644
--- a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
+++ b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
@@ -17,7 +17,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section 2
-$ pub -C myapp/example get --directory=myapp bar
+$ pub -C 'myapp/example' get --directory=myapp bar
 Resolving dependencies in myapp...
 Got dependencies in myapp!
 
@@ -40,7 +40,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section 5
-$ pub get bar -C myapp/example
+$ pub get bar -C 'myapp/example'
 Resolving dependencies in myapp/example...
 + foo 1.0.0
 + test_pkg 1.0.0 from path myapp
@@ -49,7 +49,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section 6
-$ pub get bar -C myapp/example2
+$ pub get bar -C 'myapp/example2'
 Resolving dependencies in myapp/example2...
 [STDERR] Error on line 1, column 9 of myapp/pubspec.yaml: "name" field doesn't match expected name "myapp".
 [STDERR]   ╷
@@ -61,7 +61,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section 7
-$ pub get bar -C myapp/broken_dir
+$ pub get bar -C 'myapp/broken_dir'
 [STDERR] Could not find a file named "pubspec.yaml" in "$SANDBOX/myapp/broken_dir".
 [EXIT CODE] 66
 
@@ -84,7 +84,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section 10
-$ pub run -C myapp bin/app.dart
+$ pub run -C myapp 'bin/app.dart'
 Building package executable...
 Built test_pkg:app.
 Hi