[flutter_migrate] base boilerplate files (#2694)

diff --git a/packages/flutter_migrate/analysis_options.yaml b/packages/flutter_migrate/analysis_options.yaml
new file mode 100644
index 0000000..9d4b822
--- /dev/null
+++ b/packages/flutter_migrate/analysis_options.yaml
@@ -0,0 +1,7 @@
+# Specify analysis options.
+
+include: ../../analysis_options.yaml
+
+linter:
+  rules:
+    public_member_api_docs: false # Standalone executable, no public API
diff --git a/packages/flutter_migrate/lib/src/base/command.dart b/packages/flutter_migrate/lib/src/base/command.dart
new file mode 100644
index 0000000..18604d7
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/command.dart
@@ -0,0 +1,74 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:args/command_runner.dart';
+
+enum ExitStatus {
+  success,
+  warning,
+  fail,
+  killed,
+}
+
+class CommandResult {
+  const CommandResult(this.exitStatus);
+
+  /// A command that succeeded. It is used to log the result of a command invocation.
+  factory CommandResult.success() {
+    return const CommandResult(ExitStatus.success);
+  }
+
+  /// A command that exited with a warning. It is used to log the result of a command invocation.
+  factory CommandResult.warning() {
+    return const CommandResult(ExitStatus.warning);
+  }
+
+  /// A command that failed. It is used to log the result of a command invocation.
+  factory CommandResult.fail() {
+    return const CommandResult(ExitStatus.fail);
+  }
+
+  final ExitStatus exitStatus;
+
+  @override
+  String toString() {
+    switch (exitStatus) {
+      case ExitStatus.success:
+        return 'success';
+      case ExitStatus.warning:
+        return 'warning';
+      case ExitStatus.fail:
+        return 'fail';
+      case ExitStatus.killed:
+        return 'killed';
+    }
+  }
+}
+
+abstract class MigrateCommand extends Command<void> {
+  @override
+  Future<void> run() async {
+    await runCommand();
+  }
+
+  Future<CommandResult> runCommand();
+
+  /// Gets the parsed command-line option named [name] as a `bool?`.
+  bool? boolArg(String name) {
+    if (!argParser.options.containsKey(name)) {
+      return null;
+    }
+    return argResults == null ? null : argResults![name] as bool;
+  }
+
+  String? stringArg(String name) {
+    if (!argParser.options.containsKey(name)) {
+      return null;
+    }
+    return argResults == null ? null : argResults![name] as String?;
+  }
+
+  /// Gets the parsed command-line option named [name] as an `int`.
+  int? intArg(String name) => argResults?[name] as int?;
+}
diff --git a/packages/flutter_migrate/lib/src/base/common.dart b/packages/flutter_migrate/lib/src/base/common.dart
new file mode 100644
index 0000000..70804f5
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/common.dart
@@ -0,0 +1,287 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io';
+
+import 'file_system.dart';
+
+/// Throw a specialized exception for expected situations
+/// where the tool should exit with a clear message to the user
+/// and no stack trace unless the --verbose option is specified.
+/// For example: network errors.
+Never throwToolExit(String? message, {int? exitCode}) {
+  throw ToolExit(message, exitCode: exitCode);
+}
+
+/// Specialized exception for expected situations
+/// where the tool should exit with a clear message to the user
+/// and no stack trace unless the --verbose option is specified.
+/// For example: network errors.
+class ToolExit implements Exception {
+  ToolExit(this.message, {this.exitCode});
+
+  final String? message;
+  final int? exitCode;
+
+  @override
+  String toString() => 'Error: $message';
+}
+
+/// Return the name of an enum item.
+String getEnumName(dynamic enumItem) {
+  final String name = '$enumItem';
+  final int index = name.indexOf('.');
+  return index == -1 ? name : name.substring(index + 1);
+}
+
+/// Runs [fn] with special handling of asynchronous errors.
+///
+/// If the execution of [fn] does not throw a synchronous exception, and if the
+/// [Future] returned by [fn] is completed with a value, then the [Future]
+/// returned by [asyncGuard] is completed with that value if it has not already
+/// been completed with an error.
+///
+/// If the execution of [fn] throws a synchronous exception, and no [onError]
+/// callback is provided, then the [Future] returned by [asyncGuard] is
+/// completed with an error whose object and stack trace are given by the
+/// synchronous exception. If an [onError] callback is provided, then the
+/// [Future] returned by [asyncGuard] is completed with its result when passed
+/// the error object and stack trace.
+///
+/// If the execution of [fn] results in an asynchronous exception that would
+/// otherwise be unhandled, and no [onError] callback is provided, then the
+/// [Future] returned by [asyncGuard] is completed with an error whose object
+/// and stack trace are given by the asynchronous exception. If an [onError]
+/// callback is provided, then the [Future] returned by [asyncGuard] is
+/// completed with its result when passed the error object and stack trace.
+///
+/// After the returned [Future] is completed, whether it be with a value or an
+/// error, all further errors resulting from the execution of [fn] are ignored.
+///
+/// Rationale:
+///
+/// Consider the following snippet:
+/// ```
+/// try {
+///   await foo();
+///   ...
+/// } catch (e) {
+///   ...
+/// }
+/// ```
+/// If the [Future] returned by `foo` is completed with an error, that error is
+/// handled by the catch block. However, if `foo` spawns an asynchronous
+/// operation whose errors are unhandled, those errors will not be caught by
+/// the catch block, and will instead propagate to the containing [Zone]. This
+/// behavior is non-intuitive to programmers expecting the `catch` to catch all
+/// the errors resulting from the code under the `try`.
+///
+/// As such, it would be convenient if the `try {} catch {}` here could handle
+/// not only errors completing the awaited [Future]s it contains, but also
+/// any otherwise unhandled asynchronous errors occurring as a result of awaited
+/// expressions. This is how `await` is often assumed to work, which leads to
+/// unexpected unhandled exceptions.
+///
+/// [asyncGuard] is intended to wrap awaited expressions occurring in a `try`
+/// block. The behavior described above gives the behavior that users
+/// intuitively expect from `await`. Consider the snippet:
+/// ```
+/// try {
+///   await asyncGuard(() async {
+///     var c = Completer();
+///     c.completeError('Error');
+///   });
+/// } catch (e) {
+///   // e is 'Error';
+/// }
+/// ```
+/// Without the [asyncGuard] the error 'Error' would be propagated to the
+/// error handler of the containing [Zone]. With the [asyncGuard], the error
+/// 'Error' is instead caught by the `catch`.
+///
+/// [asyncGuard] also accepts an [onError] callback for situations in which
+/// completing the returned [Future] with an error is not appropriate.
+/// For example, it is not always possible to immediately await the returned
+/// [Future]. In these cases, an [onError] callback is needed to prevent an
+/// error from propagating to the containing [Zone].
+///
+/// [onError] must have type `FutureOr<T> Function(Object error)` or
+/// `FutureOr<T> Function(Object error, StackTrace stackTrace)` otherwise an
+/// [ArgumentError] will be thrown synchronously.
+Future<T> asyncGuard<T>(
+  Future<T> Function() fn, {
+  Function? onError,
+}) {
+  if (onError != null &&
+      onError is! _UnaryOnError<T> &&
+      onError is! _BinaryOnError<T>) {
+    throw ArgumentError('onError must be a unary function accepting an Object, '
+        'or a binary function accepting an Object and '
+        'StackTrace. onError must return a T');
+  }
+  final Completer<T> completer = Completer<T>();
+
+  void handleError(Object e, StackTrace s) {
+    if (completer.isCompleted) {
+      return;
+    }
+    if (onError == null) {
+      completer.completeError(e, s);
+      return;
+    }
+    if (onError is _BinaryOnError<T>) {
+      completer.complete(onError(e, s));
+    } else if (onError is _UnaryOnError<T>) {
+      completer.complete(onError(e));
+    }
+  }
+
+  runZoned<void>(() async {
+    try {
+      final T result = await fn();
+      if (!completer.isCompleted) {
+        completer.complete(result);
+      }
+      // This catches all exceptions so that they can be propagated to the
+      // caller-supplied error handling or the completer.
+    } catch (e, s) {
+      // ignore: avoid_catches_without_on_clauses, forwards to Future
+      handleError(e, s);
+    }
+    // ignore: deprecated_member_use
+  }, onError: (Object e, StackTrace s) {
+    handleError(e, s);
+  });
+
+  return completer.future;
+}
+
+typedef _UnaryOnError<T> = FutureOr<T> Function(Object error);
+typedef _BinaryOnError<T> = FutureOr<T> Function(
+    Object error, StackTrace stackTrace);
+
+/// Whether the test is running in a web browser compiled to JavaScript.
+///
+/// See also:
+///
+///  * [kIsWeb], the equivalent constant in the `foundation` library.
+const bool isBrowser = identical(0, 0.0);
+
+/// Whether the test is running on the Windows operating system.
+///
+/// This does not include tests compiled to JavaScript running in a browser on
+/// the Windows operating system.
+///
+/// See also:
+///
+///  * [isBrowser], which reports true for tests running in browsers.
+bool get isWindows {
+  if (isBrowser) {
+    return false;
+  }
+  return Platform.isWindows;
+}
+
+/// Whether the test is running on the macOS operating system.
+///
+/// This does not include tests compiled to JavaScript running in a browser on
+/// the macOS operating system.
+///
+/// See also:
+///
+///  * [isBrowser], which reports true for tests running in browsers.
+bool get isMacOS {
+  if (isBrowser) {
+    return false;
+  }
+  return Platform.isMacOS;
+}
+
+/// Whether the test is running on the Linux operating system.
+///
+/// This does not include tests compiled to JavaScript running in a browser on
+/// the Linux operating system.
+///
+/// See also:
+///
+///  * [isBrowser], which reports true for tests running in browsers.
+bool get isLinux {
+  if (isBrowser) {
+    return false;
+  }
+  return Platform.isLinux;
+}
+
+String? flutterRoot;
+
+/// Determine the absolute and normalized path for the root of the current
+/// Flutter checkout.
+///
+/// This method has a series of fallbacks for determining the repo location. The
+/// first success will immediately return the root without further checks.
+///
+/// The order of these tests is:
+///   1. FLUTTER_ROOT environment variable contains the path.
+///   2. Platform script is a data URI scheme, returning `../..` to support
+///      tests run from `packages/flutter_tools`.
+///   3. Platform script is package URI scheme, returning the grandparent directory
+///      of the package config file location from `packages/flutter_tools/.packages`.
+///   4. Platform script file path is the snapshot path generated by `bin/flutter`,
+///      returning the grandparent directory from `bin/cache`.
+///   5. Platform script file name is the entrypoint in `packages/flutter_tools/bin/flutter_tools.dart`,
+///      returning the 4th parent directory.
+///   6. The current directory
+///
+/// If an exception is thrown during any of these checks, an error message is
+/// printed and `.` is returned by default (6).
+String defaultFlutterRoot({
+  required FileSystem fileSystem,
+}) {
+  const String kFlutterRootEnvironmentVariableName =
+      'FLUTTER_ROOT'; // should point to //flutter/ (root of flutter/flutter repo)
+  const String kSnapshotFileName =
+      'flutter_tools.snapshot'; // in //flutter/bin/cache/
+  const String kFlutterToolsScriptFileName =
+      'flutter_tools.dart'; // in //flutter/packages/flutter_tools/bin/
+  String normalize(String path) {
+    return fileSystem.path.normalize(fileSystem.path.absolute(path));
+  }
+
+  if (Platform.environment.containsKey(kFlutterRootEnvironmentVariableName)) {
+    return normalize(
+        Platform.environment[kFlutterRootEnvironmentVariableName]!);
+  }
+  try {
+    if (Platform.script.scheme == 'data') {
+      return normalize('../..'); // The tool is running as a test.
+    }
+    final String Function(String) dirname = fileSystem.path.dirname;
+
+    if (Platform.script.scheme == 'package') {
+      final String packageConfigPath =
+          Uri.parse(Platform.packageConfig!).toFilePath(
+        windows: isWindows,
+      );
+      return normalize(dirname(dirname(dirname(packageConfigPath))));
+    }
+
+    if (Platform.script.scheme == 'file') {
+      final String script = Platform.script.toFilePath(
+        windows: isWindows,
+      );
+      if (fileSystem.path.basename(script) == kSnapshotFileName) {
+        return normalize(dirname(dirname(fileSystem.path.dirname(script))));
+      }
+      if (fileSystem.path.basename(script) == kFlutterToolsScriptFileName) {
+        return normalize(dirname(dirname(dirname(dirname(script)))));
+      }
+    }
+  } on Exception catch (error) {
+    // There is currently no logger attached since this is computed at startup.
+    // ignore: avoid_print
+    print('$error');
+  }
+  return normalize('.');
+}
diff --git a/packages/flutter_migrate/lib/src/base/context.dart b/packages/flutter_migrate/lib/src/base/context.dart
new file mode 100644
index 0000000..ea1dd4d
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/context.dart
@@ -0,0 +1,199 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:collection';
+
+import 'package:meta/meta.dart';
+
+/// Generates an [AppContext] value.
+///
+/// Generators are allowed to return `null`, in which case the context will
+/// store the `null` value as the value for that type.
+typedef Generator = dynamic Function();
+
+/// An exception thrown by [AppContext] when you try to get a [Type] value from
+/// the context, and the instantiation of the value results in a dependency
+/// cycle.
+class ContextDependencyCycleException implements Exception {
+  ContextDependencyCycleException._(this.cycle);
+
+  /// The dependency cycle (last item depends on first item).
+  final List<Type> cycle;
+
+  @override
+  String toString() => 'Dependency cycle detected: ${cycle.join(' -> ')}';
+}
+
+/// The Zone key used to look up the [AppContext].
+@visibleForTesting
+const Object contextKey = _Key.key;
+
+/// The current [AppContext], as determined by the [Zone] hierarchy.
+///
+/// This will be the first context found as we scan up the zone hierarchy, or
+/// the "root" context if a context cannot be found in the hierarchy. The root
+/// context will not have any values associated with it.
+///
+/// This is guaranteed to never return `null`.
+AppContext get context =>
+    Zone.current[contextKey] as AppContext? ?? AppContext._root;
+
+/// A lookup table (mapping types to values) and an implied scope, in which
+/// code is run.
+///
+/// [AppContext] is used to define a singleton injection context for code that
+/// is run within it. Each time you call [run], a child context (and a new
+/// scope) is created.
+///
+/// Child contexts are created and run using zones. To read more about how
+/// zones work, see https://api.dart.dev/stable/dart-async/Zone-class.html.
+class AppContext {
+  AppContext._(
+    this._parent,
+    this.name, [
+    this._overrides = const <Type, Generator>{},
+    this._fallbacks = const <Type, Generator>{},
+  ]);
+
+  final String? name;
+  final AppContext? _parent;
+  final Map<Type, Generator> _overrides;
+  final Map<Type, Generator> _fallbacks;
+  final Map<Type, dynamic> _values = <Type, dynamic>{};
+
+  List<Type>? _reentrantChecks;
+
+  /// Bootstrap context.
+  static final AppContext _root = AppContext._(null, 'ROOT');
+
+  dynamic _boxNull(dynamic value) => value ?? _BoxedNull.instance;
+
+  dynamic _unboxNull(dynamic value) =>
+      value == _BoxedNull.instance ? null : value;
+
+  /// Returns the generated value for [type] if such a generator exists.
+  ///
+  /// If [generators] does not contain a mapping for the specified [type], this
+  /// returns `null`.
+  ///
+  /// If a generator existed and generated a `null` value, this will return a
+  /// boxed value indicating null.
+  ///
+  /// If a value for [type] has already been generated by this context, the
+  /// existing value will be returned, and the generator will not be invoked.
+  ///
+  /// If the generator ends up triggering a reentrant call, it signals a
+  /// dependency cycle, and a [ContextDependencyCycleException] will be thrown.
+  dynamic _generateIfNecessary(Type type, Map<Type, Generator> generators) {
+    if (!generators.containsKey(type)) {
+      return null;
+    }
+
+    return _values.putIfAbsent(type, () {
+      _reentrantChecks ??= <Type>[];
+
+      final int index = _reentrantChecks!.indexOf(type);
+      if (index >= 0) {
+        // We're already in the process of trying to generate this type.
+        throw ContextDependencyCycleException._(
+            UnmodifiableListView<Type>(_reentrantChecks!.sublist(index)));
+      }
+
+      _reentrantChecks!.add(type);
+      try {
+        return _boxNull(generators[type]!());
+      } finally {
+        _reentrantChecks!.removeLast();
+        if (_reentrantChecks!.isEmpty) {
+          _reentrantChecks = null;
+        }
+      }
+    });
+  }
+
+  /// Gets the value associated with the specified [type], or `null` if no
+  /// such value has been associated.
+  T? get<T>() {
+    dynamic value = _generateIfNecessary(T, _overrides);
+    if (value == null && _parent != null) {
+      value = _parent!.get<T>();
+    }
+    return _unboxNull(value ?? _generateIfNecessary(T, _fallbacks)) as T?;
+  }
+
+  /// Runs [body] in a child context and returns the value returned by [body].
+  ///
+  /// If [overrides] is specified, the child context will return corresponding
+  /// values when consulted via [operator[]].
+  ///
+  /// If [fallbacks] is specified, the child context will return corresponding
+  /// values when consulted via [operator[]] only if its parent context didn't
+  /// return such a value.
+  ///
+  /// If [name] is specified, the child context will be assigned the given
+  /// name. This is useful for debugging purposes and is analogous to naming a
+  /// thread in Java.
+  Future<V> run<V>({
+    required FutureOr<V> Function() body,
+    String? name,
+    Map<Type, Generator>? overrides,
+    Map<Type, Generator>? fallbacks,
+    ZoneSpecification? zoneSpecification,
+  }) async {
+    final AppContext child = AppContext._(
+      this,
+      name,
+      Map<Type, Generator>.unmodifiable(overrides ?? const <Type, Generator>{}),
+      Map<Type, Generator>.unmodifiable(fallbacks ?? const <Type, Generator>{}),
+    );
+    return runZoned<Future<V>>(
+      () async => await body(),
+      zoneValues: <_Key, AppContext>{_Key.key: child},
+      zoneSpecification: zoneSpecification,
+    );
+  }
+
+  @override
+  String toString() {
+    final StringBuffer buf = StringBuffer();
+    String indent = '';
+    AppContext? ctx = this;
+    while (ctx != null) {
+      buf.write('AppContext');
+      if (ctx.name != null) {
+        buf.write('[${ctx.name}]');
+      }
+      if (ctx._overrides.isNotEmpty) {
+        buf.write('\n$indent  overrides: [${ctx._overrides.keys.join(', ')}]');
+      }
+      if (ctx._fallbacks.isNotEmpty) {
+        buf.write('\n$indent  fallbacks: [${ctx._fallbacks.keys.join(', ')}]');
+      }
+      if (ctx._parent != null) {
+        buf.write('\n$indent  parent: ');
+      }
+      ctx = ctx._parent;
+      indent += '  ';
+    }
+    return buf.toString();
+  }
+}
+
+/// Private key used to store the [AppContext] in the [Zone].
+class _Key {
+  const _Key();
+
+  static const _Key key = _Key();
+
+  @override
+  String toString() => 'context';
+}
+
+/// Private object that denotes a generated `null` value.
+class _BoxedNull {
+  const _BoxedNull();
+
+  static const _BoxedNull instance = _BoxedNull();
+}
diff --git a/packages/flutter_migrate/lib/src/base/file_system.dart b/packages/flutter_migrate/lib/src/base/file_system.dart
new file mode 100644
index 0000000..ee5eaf8
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/file_system.dart
@@ -0,0 +1,178 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+
+import 'package:file/file.dart';
+import 'package:file/local.dart' as local_fs;
+import 'package:meta/meta.dart';
+
+import 'common.dart';
+import 'io.dart';
+import 'logger.dart';
+import 'signals.dart';
+
+// package:file/local.dart must not be exported. This exposes LocalFileSystem,
+// which we override to ensure that temporary directories are cleaned up when
+// the tool is killed by a signal.
+export 'package:file/file.dart';
+
+/// Exception indicating that a file that was expected to exist was not found.
+class FileNotFoundException implements IOException {
+  const FileNotFoundException(this.path);
+
+  final String path;
+
+  @override
+  String toString() => 'File not found: $path';
+}
+
+/// Return a relative path if [fullPath] is contained by the cwd, else return an
+/// absolute path.
+String getDisplayPath(String fullPath, FileSystem fileSystem) {
+  final String cwd =
+      fileSystem.currentDirectory.path + fileSystem.path.separator;
+  return fullPath.startsWith(cwd) ? fullPath.substring(cwd.length) : fullPath;
+}
+
+/// This class extends [local_fs.LocalFileSystem] in order to clean up
+/// directories and files that the tool creates under the system temporary
+/// directory when the tool exits either normally or when killed by a signal.
+class LocalFileSystem extends local_fs.LocalFileSystem {
+  LocalFileSystem(this._signals, this._fatalSignals, this.shutdownHooks);
+
+  @visibleForTesting
+  LocalFileSystem.test({
+    required Signals signals,
+    List<ProcessSignal> fatalSignals = Signals.defaultExitSignals,
+  }) : this(signals, fatalSignals, ShutdownHooks());
+
+  Directory? _systemTemp;
+  final Map<ProcessSignal, Object> _signalTokens = <ProcessSignal, Object>{};
+
+  final ShutdownHooks shutdownHooks;
+
+  Future<void> dispose() async {
+    _tryToDeleteTemp();
+    for (final MapEntry<ProcessSignal, Object> signalToken
+        in _signalTokens.entries) {
+      await _signals.removeHandler(signalToken.key, signalToken.value);
+    }
+    _signalTokens.clear();
+  }
+
+  final Signals _signals;
+  final List<ProcessSignal> _fatalSignals;
+
+  void _tryToDeleteTemp() {
+    try {
+      if (_systemTemp?.existsSync() ?? false) {
+        _systemTemp?.deleteSync(recursive: true);
+      }
+    } on FileSystemException {
+      // ignore
+    }
+    _systemTemp = null;
+  }
+
+  // This getter returns a fresh entry under /tmp, like
+  // /tmp/flutter_tools.abcxyz, then the rest of the tool creates /tmp entries
+  // under that, like /tmp/flutter_tools.abcxyz/flutter_build_stuff.123456.
+  // Right before exiting because of a signal or otherwise, we delete
+  // /tmp/flutter_tools.abcxyz, not the whole of /tmp.
+  @override
+  Directory get systemTempDirectory {
+    if (_systemTemp == null) {
+      if (!superSystemTempDirectory.existsSync()) {
+        throwToolExit(
+            'Your system temp directory (${superSystemTempDirectory.path}) does not exist. '
+            'Did you set an invalid override in your environment? See issue https://github.com/flutter/flutter/issues/74042 for more context.');
+      }
+      _systemTemp = superSystemTempDirectory.createTempSync('flutter_tools.')
+        ..createSync(recursive: true);
+      // Make sure that the temporary directory is cleaned up if the tool is
+      // killed by a signal.
+      for (final ProcessSignal signal in _fatalSignals) {
+        final Object token = _signals.addHandler(
+          signal,
+          (ProcessSignal _) {
+            _tryToDeleteTemp();
+          },
+        );
+        _signalTokens[signal] = token;
+      }
+      // Make sure that the temporary directory is cleaned up when the tool
+      // exits normally.
+      shutdownHooks.addShutdownHook(
+        _tryToDeleteTemp,
+      );
+    }
+    return _systemTemp!;
+  }
+
+  // This only exist because the memory file system does not support a systemTemp that does not exists #74042
+  @visibleForTesting
+  Directory get superSystemTempDirectory => super.systemTempDirectory;
+}
+
+/// A function that will be run before the VM exits.
+typedef ShutdownHook = FutureOr<void> Function();
+
+abstract class ShutdownHooks {
+  factory ShutdownHooks() => _DefaultShutdownHooks();
+
+  /// Registers a [ShutdownHook] to be executed before the VM exits.
+  void addShutdownHook(ShutdownHook shutdownHook);
+
+  @visibleForTesting
+  List<ShutdownHook> get registeredHooks;
+
+  /// Runs all registered shutdown hooks and returns a future that completes when
+  /// all such hooks have finished.
+  ///
+  /// Shutdown hooks will be run in groups by their [ShutdownStage]. All shutdown
+  /// hooks within a given stage will be started in parallel and will be
+  /// guaranteed to run to completion before shutdown hooks in the next stage are
+  /// started.
+  ///
+  /// This class is constructed before the [Logger], so it cannot be direct
+  /// injected in the constructor.
+  Future<void> runShutdownHooks(Logger logger);
+}
+
+class _DefaultShutdownHooks implements ShutdownHooks {
+  _DefaultShutdownHooks();
+
+  @override
+  final List<ShutdownHook> registeredHooks = <ShutdownHook>[];
+
+  bool _shutdownHooksRunning = false;
+
+  @override
+  void addShutdownHook(ShutdownHook shutdownHook) {
+    assert(!_shutdownHooksRunning);
+    registeredHooks.add(shutdownHook);
+  }
+
+  @override
+  Future<void> runShutdownHooks(Logger logger) async {
+    logger.printTrace(
+      'Running ${registeredHooks.length} shutdown hook${registeredHooks.length == 1 ? '' : 's'}',
+    );
+    _shutdownHooksRunning = true;
+    try {
+      final List<Future<dynamic>> futures = <Future<dynamic>>[];
+      for (final ShutdownHook shutdownHook in registeredHooks) {
+        final FutureOr<dynamic> result = shutdownHook();
+        if (result is Future<dynamic>) {
+          futures.add(result);
+        }
+      }
+      await Future.wait<dynamic>(futures);
+    } finally {
+      _shutdownHooksRunning = false;
+    }
+    logger.printTrace('Shutdown hooks complete');
+  }
+}
diff --git a/packages/flutter_migrate/lib/src/base/io.dart b/packages/flutter_migrate/lib/src/base/io.dart
new file mode 100644
index 0000000..8ae2813
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/io.dart
@@ -0,0 +1,340 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// This file serves as the single point of entry into the `dart:io` APIs
+/// within Flutter tools.
+///
+/// In order to make Flutter tools more testable, we use the `FileSystem` APIs
+/// in `package:file` rather than using the `dart:io` file APIs directly (see
+/// `file_system.dart`). Doing so allows us to swap out local file system
+/// access with mockable (or in-memory) file systems, making our tests hermetic
+/// vis-a-vis file system access.
+///
+/// We also use `package:platform` to provide an abstraction away from the
+/// static methods in the `dart:io` `Platform` class (see `platform.dart`). As
+/// such, do not export Platform from this file!
+///
+/// To ensure that all file system and platform API access within Flutter tools
+/// goes through the proper APIs, we forbid direct imports of `dart:io` (via a
+/// test), forcing all callers to instead import this file, which exports the
+/// blessed subset of `dart:io` that is legal to use in Flutter tools.
+///
+/// Because of the nature of this file, it is important that **platform and file
+/// APIs not be exported from `dart:io` in this file**! Moreover, be careful
+/// about any additional exports that you add to this file, as doing so will
+/// increase the API surface that we have to test in Flutter tools, and the APIs
+/// in `dart:io` can sometimes be hard to use in tests.
+
+// We allow `print()` in this file as a fallback for writing to the terminal via
+// regular stdout/stderr/stdio paths. Everything else in the flutter_tools
+// library should route terminal I/O through the [Stdio] class defined below.
+// ignore_for_file: avoid_print
+
+import 'dart:async';
+import 'dart:io' as io
+    show
+        IOSink,
+        Process,
+        ProcessSignal,
+        Stdin,
+        StdinException,
+        Stdout,
+        StdoutException,
+        stderr,
+        stdin,
+        stdout;
+
+import 'package:meta/meta.dart';
+
+import 'common.dart';
+
+export 'dart:io'
+    show
+        BytesBuilder,
+        CompressionOptions,
+        // Directory,         NO! Use `file_system.dart`
+        // File,              NO! Use `file_system.dart`
+        // FileSystemEntity,  NO! Use `file_system.dart`
+        GZipCodec,
+        HandshakeException,
+        HttpClient,
+        HttpClientRequest,
+        HttpClientResponse,
+        HttpClientResponseCompressionState,
+        HttpException,
+        HttpHeaders,
+        HttpRequest,
+        HttpResponse,
+        HttpServer,
+        HttpStatus,
+        IOException,
+        IOSink,
+        InternetAddress,
+        InternetAddressType,
+        // Link              NO! Use `file_system.dart`
+        // NetworkInterface  NO! Use `io.dart`
+        OSError,
+        Platform,
+        Process,
+        ProcessException,
+        // ProcessInfo,      NO! use `io.dart`
+        ProcessResult,
+        // ProcessSignal     NO! Use [ProcessSignal] below.
+        ProcessStartMode,
+        // RandomAccessFile  NO! Use `file_system.dart`
+        ServerSocket,
+        SignalException,
+        Socket,
+        SocketException,
+        Stdin,
+        StdinException,
+        Stdout,
+        WebSocket,
+        WebSocketException,
+        WebSocketTransformer,
+        ZLibEncoder,
+        exitCode,
+        gzip,
+        pid,
+        // stderr,           NO! Use `io.dart`
+        // stdin,            NO! Use `io.dart`
+        // stdout,           NO! Use `io.dart`
+        systemEncoding;
+
+/// A class that wraps stdout, stderr, and stdin, and exposes the allowed
+/// operations.
+///
+/// In particular, there are three ways that writing to stdout and stderr
+/// can fail. A call to stdout.write() can fail:
+///   * by throwing a regular synchronous exception,
+///   * by throwing an exception asynchronously, and
+///   * by completing the Future stdout.done with an error.
+///
+/// This class enapsulates all three so that we don't have to worry about it
+/// anywhere else.
+class Stdio {
+  Stdio();
+
+  /// Tests can provide overrides to use instead of the stdout and stderr from
+  /// dart:io.
+  @visibleForTesting
+  Stdio.test({
+    required io.Stdout stdout,
+    required io.IOSink stderr,
+  })  : _stdoutOverride = stdout,
+        _stderrOverride = stderr;
+
+  io.Stdout? _stdoutOverride;
+  io.IOSink? _stderrOverride;
+
+  // These flags exist to remember when the done Futures on stdout and stderr
+  // complete to avoid trying to write to a closed stream sink, which would
+  // generate a [StateError].
+  bool _stdoutDone = false;
+  bool _stderrDone = false;
+
+  Stream<List<int>> get stdin => io.stdin;
+
+  io.Stdout get stdout {
+    if (_stdout != null) {
+      return _stdout!;
+    }
+    _stdout = _stdoutOverride ?? io.stdout;
+    _stdout!.done.then(
+      (void _) {
+        _stdoutDone = true;
+      },
+      onError: (Object err, StackTrace st) {
+        _stdoutDone = true;
+      },
+    );
+    return _stdout!;
+  }
+
+  io.Stdout? _stdout;
+
+  @visibleForTesting
+  io.IOSink get stderr {
+    if (_stderr != null) {
+      return _stderr!;
+    }
+    _stderr = _stderrOverride ?? io.stderr;
+    _stderr!.done.then(
+      (void _) {
+        _stderrDone = true;
+      },
+      onError: (Object err, StackTrace st) {
+        _stderrDone = true;
+      },
+    );
+    return _stderr!;
+  }
+
+  io.IOSink? _stderr;
+
+  bool get hasTerminal => io.stdout.hasTerminal;
+
+  static bool? _stdinHasTerminal;
+
+  /// Determines whether there is a terminal attached.
+  ///
+  /// [io.Stdin.hasTerminal] only covers a subset of cases. In this check the
+  /// echoMode is toggled on and off to catch cases where the tool running in
+  /// a docker container thinks there is an attached terminal. This can cause
+  /// runtime errors such as "inappropriate ioctl for device" if not handled.
+  bool get stdinHasTerminal {
+    if (_stdinHasTerminal != null) {
+      return _stdinHasTerminal!;
+    }
+    if (stdin is! io.Stdin) {
+      return _stdinHasTerminal = false;
+    }
+    final io.Stdin ioStdin = stdin as io.Stdin;
+    if (!ioStdin.hasTerminal) {
+      return _stdinHasTerminal = false;
+    }
+    try {
+      final bool currentEchoMode = ioStdin.echoMode;
+      ioStdin.echoMode = !currentEchoMode;
+      ioStdin.echoMode = currentEchoMode;
+    } on io.StdinException {
+      return _stdinHasTerminal = false;
+    }
+    return _stdinHasTerminal = true;
+  }
+
+  int? get terminalColumns => hasTerminal ? stdout.terminalColumns : null;
+  int? get terminalLines => hasTerminal ? stdout.terminalLines : null;
+  bool get supportsAnsiEscapes => hasTerminal && stdout.supportsAnsiEscapes;
+
+  /// Writes [message] to [stderr], falling back on [fallback] if the write
+  /// throws any exception. The default fallback calls [print] on [message].
+  void stderrWrite(
+    String message, {
+    void Function(String, dynamic, StackTrace)? fallback,
+  }) {
+    if (!_stderrDone) {
+      _stdioWrite(stderr, message, fallback: fallback);
+      return;
+    }
+    fallback == null
+        ? print(message)
+        : fallback(
+            message,
+            const io.StdoutException('stderr is done'),
+            StackTrace.current,
+          );
+  }
+
+  /// Writes [message] to [stdout], falling back on [fallback] if the write
+  /// throws any exception. The default fallback calls [print] on [message].
+  void stdoutWrite(
+    String message, {
+    void Function(String, dynamic, StackTrace)? fallback,
+  }) {
+    if (!_stdoutDone) {
+      _stdioWrite(stdout, message, fallback: fallback);
+      return;
+    }
+    fallback == null
+        ? print(message)
+        : fallback(
+            message,
+            const io.StdoutException('stdout is done'),
+            StackTrace.current,
+          );
+  }
+
+  // Helper for [stderrWrite] and [stdoutWrite].
+  void _stdioWrite(
+    io.IOSink sink,
+    String message, {
+    void Function(String, dynamic, StackTrace)? fallback,
+  }) {
+    asyncGuard<void>(() async {
+      sink.write(message);
+    }, onError: (Object error, StackTrace stackTrace) {
+      if (fallback == null) {
+        print(message);
+      } else {
+        fallback(message, error, stackTrace);
+      }
+    });
+  }
+
+  /// Adds [stream] to [stdout].
+  Future<void> addStdoutStream(Stream<List<int>> stream) =>
+      stdout.addStream(stream);
+
+  /// Adds [stream] to [stderr].
+  Future<void> addStderrStream(Stream<List<int>> stream) =>
+      stderr.addStream(stream);
+}
+
+/// A portable version of [io.ProcessSignal].
+///
+/// Listening on signals that don't exist on the current platform is just a
+/// no-op. This is in contrast to [io.ProcessSignal], where listening to
+/// non-existent signals throws an exception.
+///
+/// This class does NOT implement io.ProcessSignal, because that class uses
+/// private fields. This means it cannot be used with, e.g., [Process.killPid].
+/// Alternative implementations of the relevant methods that take
+/// [ProcessSignal] instances are available on this class (e.g. "send").
+class ProcessSignal {
+  @visibleForTesting
+  const ProcessSignal(this._delegate);
+
+  static const ProcessSignal sigwinch =
+      PosixProcessSignal(io.ProcessSignal.sigwinch);
+  static const ProcessSignal sigterm =
+      PosixProcessSignal(io.ProcessSignal.sigterm);
+  static const ProcessSignal sigusr1 =
+      PosixProcessSignal(io.ProcessSignal.sigusr1);
+  static const ProcessSignal sigusr2 =
+      PosixProcessSignal(io.ProcessSignal.sigusr2);
+  static const ProcessSignal sigint = ProcessSignal(io.ProcessSignal.sigint);
+  static const ProcessSignal sigkill = ProcessSignal(io.ProcessSignal.sigkill);
+
+  final io.ProcessSignal _delegate;
+
+  Stream<ProcessSignal> watch() {
+    return _delegate
+        .watch()
+        .map<ProcessSignal>((io.ProcessSignal signal) => this);
+  }
+
+  /// Sends the signal to the given process (identified by pid).
+  ///
+  /// Returns true if the signal was delivered, false otherwise.
+  ///
+  /// On Windows, this can only be used with [ProcessSignal.sigterm], which
+  /// terminates the process.
+  ///
+  /// This is implemented by sending the signal using [Process.killPid].
+  bool send(int pid) {
+    assert(!isWindows || this == ProcessSignal.sigterm);
+    return io.Process.killPid(pid, _delegate);
+  }
+
+  @override
+  String toString() => _delegate.toString();
+}
+
+/// A [ProcessSignal] that is only available on Posix platforms.
+///
+/// Listening to a [_PosixProcessSignal] is a no-op on Windows.
+@visibleForTesting
+class PosixProcessSignal extends ProcessSignal {
+  const PosixProcessSignal(super.wrappedSignal);
+
+  @override
+  Stream<ProcessSignal> watch() {
+    // This uses the real platform since it invokes dart:io functionality directly.
+    if (isWindows) {
+      return const Stream<ProcessSignal>.empty();
+    }
+    return super.watch();
+  }
+}
diff --git a/packages/flutter_migrate/lib/src/base/logger.dart b/packages/flutter_migrate/lib/src/base/logger.dart
new file mode 100644
index 0000000..c84f098
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/logger.dart
@@ -0,0 +1,1395 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io';
+import 'dart:math' as math;
+import 'dart:math';
+
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+
+import 'common.dart';
+import 'io.dart';
+import 'terminal.dart' show OutputPreferences, Terminal, TerminalColor;
+
+const int kDefaultStatusPadding = 59;
+final NumberFormat kSecondsFormat = NumberFormat('0.0');
+final NumberFormat kMillisecondsFormat = NumberFormat.decimalPattern();
+
+/// Smallest column that will be used for text wrapping. If the requested column
+/// width is smaller than this, then this is what will be used.
+const int kMinColumnWidth = 10;
+
+/// A factory for generating [Stopwatch] instances for [Status] instances.
+class StopwatchFactory {
+  /// const constructor so that subclasses may be const.
+  const StopwatchFactory();
+
+  /// Create a new [Stopwatch] instance.
+  ///
+  /// The optional [name] parameter is useful in tests when there are multiple
+  /// instances being created.
+  Stopwatch createStopwatch([String name = '']) => Stopwatch();
+}
+
+typedef VoidCallback = void Function();
+
+abstract class Logger {
+  /// Whether or not this logger should print [printTrace] messages.
+  bool get isVerbose => false;
+
+  /// If true, silences the logger output.
+  bool quiet = false;
+
+  /// If true, this logger supports color output.
+  bool get supportsColor;
+
+  /// If true, this logger is connected to a terminal.
+  bool get hasTerminal;
+
+  /// If true, then [printError] has been called at least once for this logger
+  /// since the last time it was set to false.
+  bool hadErrorOutput = false;
+
+  /// If true, then [printWarning] has been called at least once for this logger
+  /// since the last time it was reset to false.
+  bool hadWarningOutput = false;
+
+  /// Causes [checkForFatalLogs] to call [throwToolExit] when it is called if
+  /// [hadWarningOutput] is true.
+  bool fatalWarnings = false;
+
+  /// Returns the terminal attached to this logger.
+  Terminal get terminal;
+
+  /// Display an error `message` to the user. Commands should use this if they
+  /// fail in some way. Errors are typically followed shortly by a call to
+  /// [throwToolExit] to terminate the run.
+  ///
+  /// The `message` argument is printed to the stderr in [TerminalColor.red] by
+  /// default.
+  ///
+  /// The `stackTrace` argument is the stack trace that will be printed if
+  /// supplied.
+  ///
+  /// The `emphasis` argument will cause the output message be printed in bold text.
+  ///
+  /// The `color` argument will print the message in the supplied color instead
+  /// of the default of red. Colors will not be printed if the output terminal
+  /// doesn't support them.
+  ///
+  /// The `indent` argument specifies the number of spaces to indent the overall
+  /// message. If wrapping is enabled in [outputPreferences], then the wrapped
+  /// lines will be indented as well.
+  ///
+  /// If `hangingIndent` is specified, then any wrapped lines will be indented
+  /// by this much more than the first line, if wrapping is enabled in
+  /// [outputPreferences].
+  ///
+  /// If `wrap` is specified, then it overrides the
+  /// `outputPreferences.wrapText` setting.
+  void printError(
+    String message, {
+    StackTrace? stackTrace,
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  });
+
+  /// Display a warning `message` to the user. Commands should use this if they
+  /// important information to convey to the user that is not fatal.
+  ///
+  /// The `message` argument is printed to the stderr in [TerminalColor.cyan] by
+  /// default.
+  ///
+  /// The `emphasis` argument will cause the output message be printed in bold text.
+  ///
+  /// The `color` argument will print the message in the supplied color instead
+  /// of the default of cyan. Colors will not be printed if the output terminal
+  /// doesn't support them.
+  ///
+  /// The `indent` argument specifies the number of spaces to indent the overall
+  /// message. If wrapping is enabled in [outputPreferences], then the wrapped
+  /// lines will be indented as well.
+  ///
+  /// If `hangingIndent` is specified, then any wrapped lines will be indented
+  /// by this much more than the first line, if wrapping is enabled in
+  /// [outputPreferences].
+  ///
+  /// If `wrap` is specified, then it overrides the
+  /// `outputPreferences.wrapText` setting.
+  void printWarning(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  });
+
+  /// Display normal output of the command. This should be used for things like
+  /// progress messages, success messages, or just normal command output.
+  ///
+  /// The `message` argument is printed to the stdout.
+  ///
+  /// The `stackTrace` argument is the stack trace that will be printed if
+  /// supplied.
+  ///
+  /// If the `emphasis` argument is true, it will cause the output message be
+  /// printed in bold text. Defaults to false.
+  ///
+  /// The `color` argument will print the message in the supplied color instead
+  /// of the default of red. Colors will not be printed if the output terminal
+  /// doesn't support them.
+  ///
+  /// If `newline` is true, then a newline will be added after printing the
+  /// status. Defaults to true.
+  ///
+  /// The `indent` argument specifies the number of spaces to indent the overall
+  /// message. If wrapping is enabled in [outputPreferences], then the wrapped
+  /// lines will be indented as well.
+  ///
+  /// If `hangingIndent` is specified, then any wrapped lines will be indented
+  /// by this much more than the first line, if wrapping is enabled in
+  /// [outputPreferences].
+  ///
+  /// If `wrap` is specified, then it overrides the
+  /// `outputPreferences.wrapText` setting.
+  void printStatus(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    bool? newline,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  });
+
+  /// Display the [message] inside a box.
+  ///
+  /// For example, this is the generated output:
+  ///
+  ///   ┌─ [title] ─┐
+  ///   │ [message] │
+  ///   └───────────┘
+  ///
+  /// If a terminal is attached, the lines in [message] are automatically wrapped based on
+  /// the available columns.
+  ///
+  /// Use this utility only to highlight a message in the logs.
+  ///
+  /// This is particularly useful when the message can be easily missed because of clutter
+  /// generated by other commands invoked by the tool.
+  ///
+  /// One common use case is to provide actionable steps in a Flutter app when a Gradle
+  /// error is printed.
+  ///
+  /// In the future, this output can be integrated with an IDE like VS Code to display a
+  /// notification, and allow the user to trigger an action. e.g. run a migration.
+  void printBox(
+    String message, {
+    String? title,
+  });
+
+  /// Use this for verbose tracing output. Users can turn this output on in order
+  /// to help diagnose issues with the toolchain or with their setup.
+  void printTrace(String message);
+
+  /// Start an indeterminate progress display.
+  ///
+  /// The `message` argument is the message to display to the user.
+  ///
+  /// The `progressId` argument provides an ID that can be used to identify
+  /// this type of progress (e.g. `hot.reload`, `hot.restart`).
+  ///
+  /// The `progressIndicatorPadding` can optionally be used to specify the width
+  /// of the space into which the `message` is placed before the progress
+  /// indicator, if any. It is ignored if the message is longer.
+  Status startProgress(
+    String message, {
+    String? progressId,
+    int progressIndicatorPadding = kDefaultStatusPadding,
+  });
+
+  /// A [SilentStatus] or an [AnonymousSpinnerStatus] (depending on whether the
+  /// terminal is fancy enough), already started.
+  Status startSpinner({
+    VoidCallback? onFinish,
+    Duration? timeout,
+    SlowWarningCallback? slowWarningCallback,
+  });
+
+  /// Clears all output.
+  void clear();
+
+  /// If [fatalWarnings] is set, causes the logger to check if
+  /// [hadWarningOutput] is true, and then to call [throwToolExit] if so.
+  ///
+  /// The [fatalWarnings] flag can be set from the command line with the
+  /// "--fatal-warnings" option on commands that support it.
+  void checkForFatalLogs() {
+    if (fatalWarnings && (hadWarningOutput || hadErrorOutput)) {
+      throwToolExit(
+          'Logger received ${hadErrorOutput ? 'error' : 'warning'} output '
+          'during the run, and "--fatal-warnings" is enabled.');
+    }
+  }
+}
+
+class StdoutLogger extends Logger {
+  StdoutLogger({
+    required this.terminal,
+    required Stdio stdio,
+    required OutputPreferences outputPreferences,
+    StopwatchFactory stopwatchFactory = const StopwatchFactory(),
+  })  : _stdio = stdio,
+        _outputPreferences = outputPreferences,
+        _stopwatchFactory = stopwatchFactory;
+
+  @override
+  final Terminal terminal;
+  final OutputPreferences _outputPreferences;
+  final Stdio _stdio;
+  final StopwatchFactory _stopwatchFactory;
+
+  Status? _status;
+
+  @override
+  bool get isVerbose => false;
+
+  @override
+  bool get supportsColor => terminal.supportsColor;
+
+  @override
+  bool get hasTerminal => _stdio.stdinHasTerminal;
+
+  @override
+  void printError(
+    String message, {
+    StackTrace? stackTrace,
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    hadErrorOutput = true;
+    _status?.pause();
+    message = wrapText(
+      message,
+      indent: indent,
+      hangingIndent: hangingIndent,
+      shouldWrap: wrap ?? _outputPreferences.wrapText,
+      columnWidth: _outputPreferences.wrapColumn,
+    );
+    if (emphasis ?? false) {
+      message = terminal.bolden(message);
+    }
+    message = terminal.color(message, color ?? TerminalColor.red);
+    writeToStdErr('$message\n');
+    if (stackTrace != null) {
+      writeToStdErr('$stackTrace\n');
+    }
+    _status?.resume();
+  }
+
+  @override
+  void printWarning(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    hadWarningOutput = true;
+    _status?.pause();
+    message = wrapText(
+      message,
+      indent: indent,
+      hangingIndent: hangingIndent,
+      shouldWrap: wrap ?? _outputPreferences.wrapText,
+      columnWidth: _outputPreferences.wrapColumn,
+    );
+    if (emphasis ?? false) {
+      message = terminal.bolden(message);
+    }
+    message = terminal.color(message, color ?? TerminalColor.cyan);
+    writeToStdErr('$message\n');
+    _status?.resume();
+  }
+
+  @override
+  void printStatus(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    bool? newline,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    _status?.pause();
+    message = wrapText(
+      message,
+      indent: indent,
+      hangingIndent: hangingIndent,
+      shouldWrap: wrap ?? _outputPreferences.wrapText,
+      columnWidth: _outputPreferences.wrapColumn,
+    );
+    if (emphasis ?? false) {
+      message = terminal.bolden(message);
+    }
+    if (color != null) {
+      message = terminal.color(message, color);
+    }
+    if (newline ?? true) {
+      message = '$message\n';
+    }
+    writeToStdOut(message);
+    _status?.resume();
+  }
+
+  @override
+  void printBox(
+    String message, {
+    String? title,
+  }) {
+    _status?.pause();
+    _generateBox(
+      title: title,
+      message: message,
+      wrapColumn: _outputPreferences.wrapColumn,
+      terminal: terminal,
+      write: writeToStdOut,
+    );
+    _status?.resume();
+  }
+
+  @protected
+  void writeToStdOut(String message) => _stdio.stdoutWrite(message);
+
+  @protected
+  void writeToStdErr(String message) => _stdio.stderrWrite(message);
+
+  @override
+  void printTrace(String message) {}
+
+  @override
+  Status startProgress(
+    String message, {
+    String? progressId,
+    int progressIndicatorPadding = kDefaultStatusPadding,
+  }) {
+    if (_status != null) {
+      // Ignore nested progresses; return a no-op status object.
+      return SilentStatus(
+        stopwatch: _stopwatchFactory.createStopwatch(),
+      )..start();
+    }
+    if (supportsColor) {
+      _status = SpinnerStatus(
+        message: message,
+        padding: progressIndicatorPadding,
+        onFinish: _clearStatus,
+        stdio: _stdio,
+        stopwatch: _stopwatchFactory.createStopwatch(),
+        terminal: terminal,
+      )..start();
+    } else {
+      _status = SummaryStatus(
+        message: message,
+        padding: progressIndicatorPadding,
+        onFinish: _clearStatus,
+        stdio: _stdio,
+        stopwatch: _stopwatchFactory.createStopwatch(),
+      )..start();
+    }
+    return _status!;
+  }
+
+  @override
+  Status startSpinner({
+    VoidCallback? onFinish,
+    Duration? timeout,
+    SlowWarningCallback? slowWarningCallback,
+  }) {
+    if (_status != null || !supportsColor) {
+      return SilentStatus(
+        onFinish: onFinish,
+        stopwatch: _stopwatchFactory.createStopwatch(),
+      )..start();
+    }
+    _status = AnonymousSpinnerStatus(
+      onFinish: () {
+        if (onFinish != null) {
+          onFinish();
+        }
+        _clearStatus();
+      },
+      stdio: _stdio,
+      stopwatch: _stopwatchFactory.createStopwatch(),
+      terminal: terminal,
+      timeout: timeout,
+      slowWarningCallback: slowWarningCallback,
+    )..start();
+    return _status!;
+  }
+
+  void _clearStatus() {
+    _status = null;
+  }
+
+  @override
+  void clear() {
+    _status?.pause();
+    writeToStdOut('${terminal.clearScreen()}\n');
+    _status?.resume();
+  }
+}
+
+/// A [StdoutLogger] which replaces Unicode characters that cannot be printed to
+/// the Windows console with alternative symbols.
+///
+/// By default, Windows uses either "Consolas" or "Lucida Console" as fonts to
+/// render text in the console. Both fonts only have a limited character set.
+/// Unicode characters, that are not available in either of the two default
+/// fonts, should be replaced by this class with printable symbols. Otherwise,
+/// they will show up as the unrepresentable character symbol '�'.
+class WindowsStdoutLogger extends StdoutLogger {
+  WindowsStdoutLogger({
+    required super.terminal,
+    required super.stdio,
+    required super.outputPreferences,
+    super.stopwatchFactory,
+  });
+
+  @override
+  void writeToStdOut(String message) {
+    final String windowsMessage = terminal.supportsEmoji
+        ? message
+        : message
+            .replaceAll('🔥', '')
+            .replaceAll('🖼️', '')
+            .replaceAll('✗', 'X')
+            .replaceAll('✓', '√')
+            .replaceAll('🔨', '')
+            .replaceAll('💪', '')
+            .replaceAll('⚠️', '!')
+            .replaceAll('✏️', '');
+    _stdio.stdoutWrite(windowsMessage);
+  }
+}
+
+typedef _Writter = void Function(String message);
+
+/// Wraps the message in a box, and writes the bytes by calling [write].
+///
+///  Example output:
+///
+///   ┌─ [title] ─┐
+///   │ [message] │
+///   └───────────┘
+///
+/// When [title] is provided, the box will have a title above it.
+///
+/// The box width never exceeds [wrapColumn].
+///
+/// If [wrapColumn] is not provided, the default value is 100.
+void _generateBox({
+  required String message,
+  required int wrapColumn,
+  required _Writter write,
+  required Terminal terminal,
+  String? title,
+}) {
+  const int kPaddingLeftRight = 1;
+  const int kEdges = 2;
+
+  final int maxTextWidthPerLine = wrapColumn - kEdges - kPaddingLeftRight * 2;
+  final List<String> lines =
+      wrapText(message, shouldWrap: true, columnWidth: maxTextWidthPerLine)
+          .split('\n');
+  final List<int> lineWidth =
+      lines.map((String line) => _getColumnSize(line)).toList();
+  final int maxColumnSize =
+      lineWidth.reduce((int currLen, int maxLen) => max(currLen, maxLen));
+  final int textWidth = min(maxColumnSize, maxTextWidthPerLine);
+  final int textWithPaddingWidth = textWidth + kPaddingLeftRight * 2;
+
+  write('\n');
+
+  // Write `┌─ [title] ─┐`.
+  write('┌');
+  write('─');
+  if (title == null) {
+    write('─' * (textWithPaddingWidth - 1));
+  } else {
+    write(' ${terminal.bolden(title)} ');
+    write('─' * (textWithPaddingWidth - title.length - 3));
+  }
+  write('┐');
+  write('\n');
+
+  // Write `│ [message] │`.
+  for (int lineIdx = 0; lineIdx < lines.length; lineIdx++) {
+    write('│');
+    write(' ' * kPaddingLeftRight);
+    write(lines[lineIdx]);
+    final int remainingSpacesToEnd = textWidth - lineWidth[lineIdx];
+    write(' ' * (remainingSpacesToEnd + kPaddingLeftRight));
+    write('│');
+    write('\n');
+  }
+
+  // Write `└───────────┘`.
+  write('└');
+  write('─' * textWithPaddingWidth);
+  write('┘');
+  write('\n');
+}
+
+final RegExp _ansiEscapePattern =
+    RegExp('\x1B\\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]');
+
+int _getColumnSize(String line) {
+  // Remove ANSI escape characters from the string.
+  return line.replaceAll(_ansiEscapePattern, '').length;
+}
+
+class BufferLogger extends Logger {
+  BufferLogger({
+    required this.terminal,
+    required OutputPreferences outputPreferences,
+    StopwatchFactory stopwatchFactory = const StopwatchFactory(),
+    bool verbose = false,
+  })  : _outputPreferences = outputPreferences,
+        _stopwatchFactory = stopwatchFactory,
+        _verbose = verbose;
+
+  /// Create a [BufferLogger] with test preferences.
+  BufferLogger.test({
+    Terminal? terminal,
+    OutputPreferences? outputPreferences,
+    bool verbose = false,
+  })  : terminal = terminal ?? Terminal.test(),
+        _outputPreferences = outputPreferences ?? OutputPreferences.test(),
+        _stopwatchFactory = const StopwatchFactory(),
+        _verbose = verbose;
+
+  final OutputPreferences _outputPreferences;
+
+  @override
+  final Terminal terminal;
+
+  final StopwatchFactory _stopwatchFactory;
+
+  final bool _verbose;
+
+  @override
+  bool get isVerbose => _verbose;
+
+  @override
+  bool get supportsColor => terminal.supportsColor;
+
+  final StringBuffer _error = StringBuffer();
+  final StringBuffer _warning = StringBuffer();
+  final StringBuffer _status = StringBuffer();
+  final StringBuffer _trace = StringBuffer();
+  final StringBuffer _events = StringBuffer();
+
+  String get errorText => _error.toString();
+  String get warningText => _warning.toString();
+  String get statusText => _status.toString();
+  String get traceText => _trace.toString();
+  String get eventText => _events.toString();
+
+  @override
+  bool get hasTerminal => false;
+
+  @override
+  void printError(
+    String message, {
+    StackTrace? stackTrace,
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    hadErrorOutput = true;
+    _error.writeln(terminal.color(
+      wrapText(
+        message,
+        indent: indent,
+        hangingIndent: hangingIndent,
+        shouldWrap: wrap ?? _outputPreferences.wrapText,
+        columnWidth: _outputPreferences.wrapColumn,
+      ),
+      color ?? TerminalColor.red,
+    ));
+  }
+
+  @override
+  void printWarning(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    hadWarningOutput = true;
+    _warning.writeln(terminal.color(
+      wrapText(
+        message,
+        indent: indent,
+        hangingIndent: hangingIndent,
+        shouldWrap: wrap ?? _outputPreferences.wrapText,
+        columnWidth: _outputPreferences.wrapColumn,
+      ),
+      color ?? TerminalColor.cyan,
+    ));
+  }
+
+  @override
+  void printStatus(
+    String message, {
+    bool? emphasis,
+    TerminalColor? color,
+    bool? newline,
+    int? indent,
+    int? hangingIndent,
+    bool? wrap,
+  }) {
+    if (newline ?? true) {
+      _status.writeln(wrapText(
+        message,
+        indent: indent,
+        hangingIndent: hangingIndent,
+        shouldWrap: wrap ?? _outputPreferences.wrapText,
+        columnWidth: _outputPreferences.wrapColumn,
+      ));
+    } else {
+      _status.write(wrapText(
+        message,
+        indent: indent,
+        hangingIndent: hangingIndent,
+        shouldWrap: wrap ?? _outputPreferences.wrapText,
+        columnWidth: _outputPreferences.wrapColumn,
+      ));
+    }
+  }
+
+  @override
+  void printBox(
+    String message, {
+    String? title,
+  }) {
+    _generateBox(
+      title: title,
+      message: message,
+      wrapColumn: _outputPreferences.wrapColumn,
+      terminal: terminal,
+      write: _status.write,
+    );
+  }
+
+  @override
+  void printTrace(String message) => _trace.writeln(message);
+
+  @override
+  Status startProgress(
+    String message, {
+    String? progressId,
+    int progressIndicatorPadding = kDefaultStatusPadding,
+  }) {
+    assert(progressIndicatorPadding != null);
+    printStatus(message);
+    return SilentStatus(
+      stopwatch: _stopwatchFactory.createStopwatch(),
+    )..start();
+  }
+
+  @override
+  Status startSpinner({
+    VoidCallback? onFinish,
+    Duration? timeout,
+    SlowWarningCallback? slowWarningCallback,
+  }) {
+    return SilentStatus(
+      stopwatch: _stopwatchFactory.createStopwatch(),
+      onFinish: onFinish,
+    )..start();
+  }
+
+  @override
+  void clear() {
+    _error.clear();
+    _status.clear();
+    _trace.clear();
+    _events.clear();
+  }
+}
+
+typedef SlowWarningCallback = String Function();
+
+/// A [Status] class begins when start is called, and may produce progress
+/// information asynchronously.
+///
+/// The [SilentStatus] class never has any output.
+///
+/// The [SpinnerStatus] subclass shows a message with a spinner, and replaces it
+/// with timing information when stopped. When canceled, the information isn't
+/// shown. In either case, a newline is printed.
+///
+/// The [AnonymousSpinnerStatus] subclass just shows a spinner.
+///
+/// The [SummaryStatus] subclass shows only a static message (without an
+/// indicator), then updates it when the operation ends.
+///
+/// Generally, consider `logger.startProgress` instead of directly creating
+/// a [Status] or one of its subclasses.
+abstract class Status {
+  Status({
+    this.onFinish,
+    required Stopwatch stopwatch,
+    this.timeout,
+  }) : _stopwatch = stopwatch;
+
+  final VoidCallback? onFinish;
+  final Duration? timeout;
+
+  @protected
+  final Stopwatch _stopwatch;
+
+  @protected
+  String get elapsedTime {
+    if (_stopwatch.elapsed.inSeconds > 2) {
+      return _getElapsedAsSeconds(_stopwatch.elapsed);
+    }
+    return _getElapsedAsMilliseconds(_stopwatch.elapsed);
+  }
+
+  String _getElapsedAsSeconds(Duration duration) {
+    final double seconds =
+        duration.inMilliseconds / Duration.millisecondsPerSecond;
+    return '${kSecondsFormat.format(seconds)}s';
+  }
+
+  String _getElapsedAsMilliseconds(Duration duration) {
+    return '${kMillisecondsFormat.format(duration.inMilliseconds)}ms';
+  }
+
+  @visibleForTesting
+  bool get seemsSlow => timeout != null && _stopwatch.elapsed > timeout!;
+
+  /// Call to start spinning.
+  void start() {
+    assert(!_stopwatch.isRunning);
+    _stopwatch.start();
+  }
+
+  /// Call to stop spinning after success.
+  void stop() {
+    finish();
+  }
+
+  /// Call to cancel the spinner after failure or cancellation.
+  void cancel() {
+    finish();
+  }
+
+  /// Call to clear the current line but not end the progress.
+  void pause() {}
+
+  /// Call to resume after a pause.
+  void resume() {}
+
+  @protected
+  void finish() {
+    assert(_stopwatch.isRunning);
+    _stopwatch.stop();
+    onFinish?.call();
+  }
+}
+
+/// A [Status] that shows nothing.
+class SilentStatus extends Status {
+  SilentStatus({
+    required super.stopwatch,
+    super.onFinish,
+  });
+
+  @override
+  void finish() {
+    onFinish?.call();
+  }
+}
+
+/// Constructor writes [message] to [stdout]. On [cancel] or [stop], will call
+/// [onFinish]. On [stop], will additionally print out summary information.
+class SummaryStatus extends Status {
+  SummaryStatus({
+    this.message = '',
+    required super.stopwatch,
+    this.padding = kDefaultStatusPadding,
+    super.onFinish,
+    required Stdio stdio,
+  }) : _stdio = stdio;
+
+  final String message;
+  final int padding;
+  final Stdio _stdio;
+
+  bool _messageShowingOnCurrentLine = false;
+
+  @override
+  void start() {
+    _printMessage();
+    super.start();
+  }
+
+  void _writeToStdOut(String message) => _stdio.stdoutWrite(message);
+
+  void _printMessage() {
+    assert(!_messageShowingOnCurrentLine);
+    _writeToStdOut('${message.padRight(padding)}     ');
+    _messageShowingOnCurrentLine = true;
+  }
+
+  @override
+  void stop() {
+    if (!_messageShowingOnCurrentLine) {
+      _printMessage();
+    }
+    super.stop();
+    assert(_messageShowingOnCurrentLine);
+    _writeToStdOut(elapsedTime.padLeft(_kTimePadding));
+    _writeToStdOut('\n');
+  }
+
+  @override
+  void cancel() {
+    super.cancel();
+    if (_messageShowingOnCurrentLine) {
+      _writeToStdOut('\n');
+    }
+  }
+
+  @override
+  void pause() {
+    super.pause();
+    if (_messageShowingOnCurrentLine) {
+      _writeToStdOut('\n');
+      _messageShowingOnCurrentLine = false;
+    }
+  }
+}
+
+const int _kTimePadding = 8; // should fit "99,999ms"
+
+/// A kind of animated [Status] that has no message.
+///
+/// Call [pause] before outputting any text while this is running.
+class AnonymousSpinnerStatus extends Status {
+  AnonymousSpinnerStatus({
+    super.onFinish,
+    required super.stopwatch,
+    required Stdio stdio,
+    required Terminal terminal,
+    this.slowWarningCallback,
+    super.timeout,
+  })  : _stdio = stdio,
+        _terminal = terminal,
+        _animation = _selectAnimation(terminal);
+
+  final Stdio _stdio;
+  final Terminal _terminal;
+  String _slowWarning = '';
+  final SlowWarningCallback? slowWarningCallback;
+
+  static const String _backspaceChar = '\b';
+  static const String _clearChar = ' ';
+
+  static const List<String> _emojiAnimations = <String>[
+    '⣾⣽⣻⢿⡿⣟⣯⣷', // counter-clockwise
+    '⣾⣷⣯⣟⡿⢿⣻⣽', // clockwise
+    '⣾⣷⣯⣟⡿⢿⣻⣽⣷⣾⣽⣻⢿⡿⣟⣯⣷', // bouncing clockwise and counter-clockwise
+    '⣾⣷⣯⣽⣻⣟⡿⢿⣻⣟⣯⣽', // snaking
+    '⣾⣽⣻⢿⣿⣷⣯⣟⡿⣿', // alternating rain
+    '⣀⣠⣤⣦⣶⣾⣿⡿⠿⠻⠛⠋⠉⠙⠛⠟⠿⢿⣿⣷⣶⣴⣤⣄', // crawl up and down, large
+    '⠙⠚⠖⠦⢤⣠⣄⡤⠴⠲⠓⠋', // crawl up and down, small
+    '⣀⡠⠤⠔⠒⠊⠉⠑⠒⠢⠤⢄', // crawl up and down, tiny
+    '⡀⣄⣦⢷⠻⠙⠈⠀⠁⠋⠟⡾⣴⣠⢀⠀', // slide up and down
+    '⠙⠸⢰⣠⣄⡆⠇⠋', // clockwise line
+    '⠁⠈⠐⠠⢀⡀⠄⠂', // clockwise dot
+    '⢇⢣⢱⡸⡜⡎', // vertical wobble up
+    '⡇⡎⡜⡸⢸⢱⢣⢇', // vertical wobble down
+    '⡀⣀⣐⣒⣖⣶⣾⣿⢿⠿⠯⠭⠩⠉⠁⠀', // swirl
+    '⠁⠐⠄⢀⢈⢂⢠⣀⣁⣐⣄⣌⣆⣤⣥⣴⣼⣶⣷⣿⣾⣶⣦⣤⣠⣀⡀⠀⠀', // snowing and melting
+    '⠁⠋⠞⡴⣠⢀⠀⠈⠙⠻⢷⣦⣄⡀⠀⠉⠛⠲⢤⢀⠀', // falling water
+    '⠄⡢⢑⠈⠀⢀⣠⣤⡶⠞⠋⠁⠀⠈⠙⠳⣆⡀⠀⠆⡷⣹⢈⠀⠐⠪⢅⡀⠀', // fireworks
+    '⠐⢐⢒⣒⣲⣶⣷⣿⡿⡷⡧⠧⠇⠃⠁⠀⡀⡠⡡⡱⣱⣳⣷⣿⢿⢯⢧⠧⠣⠃⠂⠀⠈⠨⠸⠺⡺⡾⡿⣿⡿⡷⡗⡇⡅⡄⠄⠀⡀⡐⣐⣒⣓⣳⣻⣿⣾⣼⡼⡸⡘⡈⠈⠀', // fade
+    '⢸⡯⠭⠅⢸⣇⣀⡀⢸⣇⣸⡇⠈⢹⡏⠁⠈⢹⡏⠁⢸⣯⣭⡅⢸⡯⢕⡂⠀⠀', // text crawl
+  ];
+
+  static const List<String> _asciiAnimations = <String>[
+    r'-\|/',
+  ];
+
+  static List<String> _selectAnimation(Terminal terminal) {
+    final List<String> animations =
+        terminal.supportsEmoji ? _emojiAnimations : _asciiAnimations;
+    return animations[terminal.preferredStyle % animations.length]
+        .runes
+        .map<String>((int scalar) => String.fromCharCode(scalar))
+        .toList();
+  }
+
+  final List<String> _animation;
+
+  Timer? timer;
+  int ticks = 0;
+  int _lastAnimationFrameLength = 0;
+  bool timedOut = false;
+
+  String get _currentAnimationFrame => _animation[ticks % _animation.length];
+  int get _currentLineLength => _lastAnimationFrameLength + _slowWarning.length;
+
+  void _writeToStdOut(String message) => _stdio.stdoutWrite(message);
+
+  void _clear(int length) {
+    _writeToStdOut('${_backspaceChar * length}'
+        '${_clearChar * length}'
+        '${_backspaceChar * length}');
+  }
+
+  @override
+  void start() {
+    super.start();
+    assert(timer == null);
+    _startSpinner();
+  }
+
+  void _startSpinner() {
+    timer = Timer.periodic(const Duration(milliseconds: 100), _callback);
+    _callback(timer!);
+  }
+
+  void _callback(Timer timer) {
+    assert(this.timer == timer);
+    assert(timer != null);
+    assert(timer.isActive);
+    _writeToStdOut(_backspaceChar * _lastAnimationFrameLength);
+    ticks += 1;
+    if (seemsSlow) {
+      if (!timedOut) {
+        timedOut = true;
+        _clear(_currentLineLength);
+      }
+      if (_slowWarning == '' && slowWarningCallback != null) {
+        _slowWarning = slowWarningCallback!();
+        _writeToStdOut(_slowWarning);
+      }
+    }
+    final String newFrame = _currentAnimationFrame;
+    _lastAnimationFrameLength = newFrame.runes.length;
+    _writeToStdOut(newFrame);
+  }
+
+  @override
+  void pause() {
+    assert(timer != null);
+    assert(timer!.isActive);
+    if (_terminal.supportsColor) {
+      _writeToStdOut('\r\x1B[K'); // go to start of line and clear line
+    } else {
+      _clear(_currentLineLength);
+    }
+    _lastAnimationFrameLength = 0;
+    timer?.cancel();
+  }
+
+  @override
+  void resume() {
+    assert(timer != null);
+    assert(!timer!.isActive);
+    _startSpinner();
+  }
+
+  @override
+  void finish() {
+    assert(timer != null);
+    assert(timer!.isActive);
+    timer?.cancel();
+    timer = null;
+    _clear(_lastAnimationFrameLength);
+    _lastAnimationFrameLength = 0;
+    super.finish();
+  }
+}
+
+/// An animated version of [Status].
+///
+/// The constructor writes [message] to [stdout] with padding, then starts an
+/// indeterminate progress indicator animation.
+///
+/// On [cancel] or [stop], will call [onFinish]. On [stop], will
+/// additionally print out summary information.
+///
+/// Call [pause] before outputting any text while this is running.
+class SpinnerStatus extends AnonymousSpinnerStatus {
+  SpinnerStatus({
+    required this.message,
+    this.padding = kDefaultStatusPadding,
+    super.onFinish,
+    required super.stopwatch,
+    required super.stdio,
+    required super.terminal,
+  });
+
+  final String message;
+  final int padding;
+
+  static final String _margin =
+      AnonymousSpinnerStatus._clearChar * (5 + _kTimePadding - 1);
+
+  int _totalMessageLength = 0;
+
+  @override
+  int get _currentLineLength => _totalMessageLength + super._currentLineLength;
+
+  @override
+  void start() {
+    _printStatus();
+    super.start();
+  }
+
+  void _printStatus() {
+    final String line = '${message.padRight(padding)}$_margin';
+    _totalMessageLength = line.length;
+    _writeToStdOut(line);
+  }
+
+  @override
+  void pause() {
+    super.pause();
+    _totalMessageLength = 0;
+  }
+
+  @override
+  void resume() {
+    _printStatus();
+    super.resume();
+  }
+
+  @override
+  void stop() {
+    super.stop(); // calls finish, which clears the spinner
+    assert(_totalMessageLength > _kTimePadding);
+    _writeToStdOut(AnonymousSpinnerStatus._backspaceChar * (_kTimePadding - 1));
+    _writeToStdOut(elapsedTime.padLeft(_kTimePadding));
+    _writeToStdOut('\n');
+  }
+
+  @override
+  void cancel() {
+    super.cancel(); // calls finish, which clears the spinner
+    assert(_totalMessageLength > 0);
+    _writeToStdOut('\n');
+  }
+}
+
+/// Wraps a block of text into lines no longer than [columnWidth].
+///
+/// Tries to split at whitespace, but if that's not good enough to keep it under
+/// the limit, then it splits in the middle of a word. If [columnWidth] (minus
+/// any indent) is smaller than [kMinColumnWidth], the text is wrapped at that
+/// [kMinColumnWidth] instead.
+///
+/// Preserves indentation (leading whitespace) for each line (delimited by '\n')
+/// in the input, and will indent wrapped lines that same amount, adding
+/// [indent] spaces in addition to any existing indent.
+///
+/// If [hangingIndent] is supplied, then that many additional spaces will be
+/// added to each line, except for the first line. The [hangingIndent] is added
+/// to the specified [indent], if any. This is useful for wrapping
+/// text with a heading prefix (e.g. "Usage: "):
+///
+/// ```dart
+/// String prefix = "Usage: ";
+/// print(prefix + wrapText(invocation, indent: 2, hangingIndent: prefix.length, columnWidth: 40));
+/// ```
+///
+/// yields:
+/// ```
+///   Usage: app main_command <subcommand>
+///          [arguments]
+/// ```
+///
+/// If [outputPreferences.wrapText] is false, then the text will be returned
+/// unchanged. If [shouldWrap] is specified, then it overrides the
+/// [outputPreferences.wrapText] setting.
+///
+/// If the amount of indentation (from the text, [indent], and [hangingIndent])
+/// is such that less than [kMinColumnWidth] characters can fit in the
+/// [columnWidth], then the indent is truncated to allow the text to fit.
+String wrapText(
+  String text, {
+  required int columnWidth,
+  required bool shouldWrap,
+  int? hangingIndent,
+  int? indent,
+}) {
+  assert(columnWidth >= 0);
+  if (text == null || text.isEmpty) {
+    return '';
+  }
+  indent ??= 0;
+  hangingIndent ??= 0;
+  final List<String> splitText = text.split('\n');
+  final List<String> result = <String>[];
+  for (final String line in splitText) {
+    String trimmedText = line.trimLeft();
+    final String leadingWhitespace =
+        line.substring(0, line.length - trimmedText.length);
+    List<String> notIndented;
+    if (hangingIndent != 0) {
+      // When we have a hanging indent, we want to wrap the first line at one
+      // width, and the rest at another (offset by hangingIndent), so we wrap
+      // them twice and recombine.
+      final List<String> firstLineWrap = _wrapTextAsLines(
+        trimmedText,
+        columnWidth: columnWidth - leadingWhitespace.length - indent,
+        shouldWrap: shouldWrap,
+      );
+      notIndented = <String>[firstLineWrap.removeAt(0)];
+      trimmedText = trimmedText.substring(notIndented[0].length).trimLeft();
+      if (trimmedText.isNotEmpty) {
+        notIndented.addAll(_wrapTextAsLines(
+          trimmedText,
+          columnWidth:
+              columnWidth - leadingWhitespace.length - indent - hangingIndent,
+          shouldWrap: shouldWrap,
+        ));
+      }
+    } else {
+      notIndented = _wrapTextAsLines(
+        trimmedText,
+        columnWidth: columnWidth - leadingWhitespace.length - indent,
+        shouldWrap: shouldWrap,
+      );
+    }
+    String? hangingIndentString;
+    final String indentString = ' ' * indent;
+    result.addAll(notIndented.map<String>(
+      (String line) {
+        // Don't return any lines with just whitespace on them.
+        if (line.isEmpty) {
+          return '';
+        }
+        String truncatedIndent =
+            '$indentString${hangingIndentString ?? ''}$leadingWhitespace';
+        if (truncatedIndent.length > columnWidth - kMinColumnWidth) {
+          truncatedIndent = truncatedIndent.substring(
+              0, math.max(columnWidth - kMinColumnWidth, 0));
+        }
+        final String result = '$truncatedIndent$line';
+        hangingIndentString ??= ' ' * hangingIndent!;
+        return result;
+      },
+    ));
+  }
+  return result.join('\n');
+}
+
+/// Wraps a block of text into lines no longer than [columnWidth], starting at the
+/// [start] column, and returning the result as a list of strings.
+///
+/// Tries to split at whitespace, but if that's not good enough to keep it
+/// under the limit, then splits in the middle of a word. Preserves embedded
+/// newlines, but not indentation (it trims whitespace from each line).
+///
+/// If [columnWidth] is not specified, then the column width will be the width of the
+/// terminal window by default. If the stdout is not a terminal window, then the
+/// default will be [outputPreferences.wrapColumn].
+///
+/// The [columnWidth] is clamped to [kMinColumnWidth] at minimum (so passing negative
+/// widths is fine, for instance).
+///
+/// If [outputPreferences.wrapText] is false, then the text will be returned
+/// simply split at the newlines, but not wrapped. If [shouldWrap] is specified,
+/// then it overrides the [outputPreferences.wrapText] setting.
+List<String> _wrapTextAsLines(
+  String text, {
+  int start = 0,
+  required int columnWidth,
+  required bool shouldWrap,
+}) {
+  if (text == null || text.isEmpty) {
+    return <String>[''];
+  }
+  assert(start >= 0);
+
+  // Splits a string so that the resulting list has the same number of elements
+  // as there are visible characters in the string, but elements may include one
+  // or more adjacent ANSI sequences. Joining the list elements again will
+  // reconstitute the original string. This is useful for manipulating "visible"
+  // characters in the presence of ANSI control codes.
+  List<_AnsiRun> splitWithCodes(String input) {
+    final RegExp characterOrCode =
+        RegExp('(\u001b\\[[0-9;]*m|.)', multiLine: true);
+    List<_AnsiRun> result = <_AnsiRun>[];
+    final StringBuffer current = StringBuffer();
+    for (final Match match in characterOrCode.allMatches(input)) {
+      current.write(match[0]);
+      if (match[0]!.length < 4) {
+        // This is a regular character, write it out.
+        result.add(_AnsiRun(current.toString(), match[0]!));
+        current.clear();
+      }
+    }
+    // If there's something accumulated, then it must be an ANSI sequence, so
+    // add it to the end of the last entry so that we don't lose it.
+    if (current.isNotEmpty) {
+      if (result.isNotEmpty) {
+        result.last.original += current.toString();
+      } else {
+        // If there is nothing in the string besides control codes, then just
+        // return them as the only entry.
+        result = <_AnsiRun>[_AnsiRun(current.toString(), '')];
+      }
+    }
+    return result;
+  }
+
+  String joinRun(List<_AnsiRun> list, int start, [int? end]) {
+    return list
+        .sublist(start, end)
+        .map<String>((_AnsiRun run) => run.original)
+        .join()
+        .trim();
+  }
+
+  final List<String> result = <String>[];
+  final int effectiveLength = math.max(columnWidth - start, kMinColumnWidth);
+  for (final String line in text.split('\n')) {
+    // If the line is short enough, even with ANSI codes, then we can just add
+    // add it and move on.
+    if (line.length <= effectiveLength || !shouldWrap) {
+      result.add(line);
+      continue;
+    }
+    final List<_AnsiRun> splitLine = splitWithCodes(line);
+    if (splitLine.length <= effectiveLength) {
+      result.add(line);
+      continue;
+    }
+
+    int currentLineStart = 0;
+    int? lastWhitespace;
+    // Find the start of the current line.
+    for (int index = 0; index < splitLine.length; ++index) {
+      if (splitLine[index].character.isNotEmpty &&
+          _isWhitespace(splitLine[index])) {
+        lastWhitespace = index;
+      }
+
+      if (index - currentLineStart >= effectiveLength) {
+        // Back up to the last whitespace, unless there wasn't any, in which
+        // case we just split where we are.
+        if (lastWhitespace != null) {
+          index = lastWhitespace;
+        }
+
+        result.add(joinRun(splitLine, currentLineStart, index));
+
+        // Skip any intervening whitespace.
+        while (index < splitLine.length && _isWhitespace(splitLine[index])) {
+          index++;
+        }
+
+        currentLineStart = index;
+        lastWhitespace = null;
+      }
+    }
+    result.add(joinRun(splitLine, currentLineStart));
+  }
+  return result;
+}
+
+// Used to represent a run of ANSI control sequences next to a visible
+// character.
+class _AnsiRun {
+  _AnsiRun(this.original, this.character);
+
+  String original;
+  String character;
+}
+
+/// Returns true if the code unit at [index] in [text] is a whitespace
+/// character.
+///
+/// Based on: https://en.wikipedia.org/wiki/Whitespace_character#Unicode
+bool _isWhitespace(_AnsiRun run) {
+  final int rune = run.character.isNotEmpty ? run.character.codeUnitAt(0) : 0x0;
+  return rune >= 0x0009 && rune <= 0x000D ||
+      rune == 0x0020 ||
+      rune == 0x0085 ||
+      rune == 0x1680 ||
+      rune == 0x180E ||
+      rune >= 0x2000 && rune <= 0x200A ||
+      rune == 0x2028 ||
+      rune == 0x2029 ||
+      rune == 0x202F ||
+      rune == 0x205F ||
+      rune == 0x3000 ||
+      rune == 0xFEFF;
+}
+
+/// An abstraction for instantiation of the correct logger type.
+///
+/// Our logger class hierarchy and runtime requirements are overly complicated.
+class LoggerFactory {
+  LoggerFactory({
+    required Terminal terminal,
+    required Stdio stdio,
+    required OutputPreferences outputPreferences,
+    StopwatchFactory stopwatchFactory = const StopwatchFactory(),
+  })  : _terminal = terminal,
+        _stdio = stdio,
+        _stopwatchFactory = stopwatchFactory,
+        _outputPreferences = outputPreferences;
+
+  final Terminal _terminal;
+  final Stdio _stdio;
+  final StopwatchFactory _stopwatchFactory;
+  final OutputPreferences _outputPreferences;
+
+  /// Create the appropriate logger for the current platform and configuration.
+  Logger createLogger({
+    required bool windows,
+  }) {
+    Logger logger;
+    if (windows) {
+      logger = WindowsStdoutLogger(
+        terminal: _terminal,
+        stdio: _stdio,
+        outputPreferences: _outputPreferences,
+        stopwatchFactory: _stopwatchFactory,
+      );
+    } else {
+      logger = StdoutLogger(
+          terminal: _terminal,
+          stdio: _stdio,
+          outputPreferences: _outputPreferences,
+          stopwatchFactory: _stopwatchFactory);
+    }
+    return logger;
+  }
+}
diff --git a/packages/flutter_migrate/lib/src/base/project.dart b/packages/flutter_migrate/lib/src/base/project.dart
new file mode 100644
index 0000000..a8924d7
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/project.dart
@@ -0,0 +1,91 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:meta/meta.dart';
+
+import 'file_system.dart';
+import 'logger.dart';
+
+/// Emum for each officially supported platform.
+enum SupportedPlatform {
+  android,
+  ios,
+  linux,
+  macos,
+  web,
+  windows,
+  fuchsia,
+  root, // Special platform to represent the root project directory
+}
+
+class FlutterProjectFactory {
+  FlutterProjectFactory();
+
+  @visibleForTesting
+  final Map<String, FlutterProject> projects = <String, FlutterProject>{};
+
+  /// Returns a [FlutterProject] view of the given directory or a ToolExit error,
+  /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid.
+  FlutterProject fromDirectory(Directory directory) {
+    assert(directory != null);
+    return projects.putIfAbsent(directory.path, () {
+      return FlutterProject(directory);
+    });
+  }
+}
+
+/// Represents the contents of a Flutter project at the specified [directory].
+class FlutterProject {
+  FlutterProject(this.directory) : assert(directory != null);
+
+  /// Returns a [FlutterProject] view of the current directory or a ToolExit error,
+  /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid.
+  static FlutterProject current(FileSystem fs) =>
+      FlutterProject(fs.currentDirectory);
+
+  /// Create a [FlutterProject] and bypass the project caching.
+  @visibleForTesting
+  static FlutterProject fromDirectoryTest(Directory directory,
+      [Logger? logger]) {
+    logger ??= BufferLogger.test();
+    return FlutterProject(directory);
+  }
+
+  Directory directory;
+
+  /// The `pubspec.yaml` file of this project.
+  File get pubspecFile => directory.childFile('pubspec.yaml');
+
+  /// The `.metadata` file of this project.
+  File get metadataFile => directory.childFile('.metadata');
+
+  /// Returns a list of platform names that are supported by the project.
+  List<SupportedPlatform> getSupportedPlatforms({bool includeRoot = false}) {
+    final List<SupportedPlatform> platforms = includeRoot
+        ? <SupportedPlatform>[SupportedPlatform.root]
+        : <SupportedPlatform>[];
+    if (directory.childDirectory('android').existsSync()) {
+      platforms.add(SupportedPlatform.android);
+    }
+    if (directory.childDirectory('ios').existsSync()) {
+      platforms.add(SupportedPlatform.ios);
+    }
+    if (directory.childDirectory('web').existsSync()) {
+      platforms.add(SupportedPlatform.web);
+    }
+    if (directory.childDirectory('macos').existsSync()) {
+      platforms.add(SupportedPlatform.macos);
+    }
+    if (directory.childDirectory('linux').existsSync()) {
+      platforms.add(SupportedPlatform.linux);
+    }
+    if (directory.childDirectory('windows').existsSync()) {
+      platforms.add(SupportedPlatform.windows);
+    }
+    if (directory.childDirectory('fuchsia').existsSync()) {
+      platforms.add(SupportedPlatform.fuchsia);
+    }
+    return platforms;
+  }
+}
diff --git a/packages/flutter_migrate/lib/src/base/signals.dart b/packages/flutter_migrate/lib/src/base/signals.dart
new file mode 100644
index 0000000..265f00f
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/signals.dart
@@ -0,0 +1,154 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io';
+
+import 'package:meta/meta.dart';
+
+import 'common.dart';
+import 'io.dart';
+
+typedef SignalHandler = FutureOr<void> Function(ProcessSignal signal);
+
+/// A class that manages signal handlers.
+///
+/// Signal handlers are run in the order that they were added.
+abstract class Signals {
+  @visibleForTesting
+  factory Signals.test({
+    List<ProcessSignal> exitSignals = defaultExitSignals,
+  }) =>
+      LocalSignals._(exitSignals);
+
+  // The default list of signals that should cause the process to exit.
+  static const List<ProcessSignal> defaultExitSignals = <ProcessSignal>[
+    ProcessSignal.sigterm,
+    ProcessSignal.sigint,
+  ];
+
+  /// Adds a signal handler to run on receipt of signal.
+  ///
+  /// The handler will run after all handlers that were previously added for the
+  /// signal. The function returns an abstract token that should be provided to
+  /// removeHandler to remove the handler.
+  Object addHandler(ProcessSignal signal, SignalHandler handler);
+
+  /// Removes a signal handler.
+  ///
+  /// Removes the signal handler for the signal identified by the abstract
+  /// token parameter. Returns true if the handler was removed and false
+  /// otherwise.
+  Future<bool> removeHandler(ProcessSignal signal, Object token);
+
+  /// If a [SignalHandler] throws an error, either synchronously or
+  /// asynchronously, it will be added to this stream instead of propagated.
+  Stream<Object> get errors;
+}
+
+/// A class that manages the real dart:io signal handlers.
+///
+/// We use a singleton instance of this class to ensure that all handlers for
+/// fatal signals run before this class calls exit().
+class LocalSignals implements Signals {
+  LocalSignals._(this.exitSignals);
+
+  static LocalSignals instance = LocalSignals._(
+    Signals.defaultExitSignals,
+  );
+
+  final List<ProcessSignal> exitSignals;
+
+  // A table mapping (signal, token) -> signal handler.
+  final Map<ProcessSignal, Map<Object, SignalHandler>> _handlersTable =
+      <ProcessSignal, Map<Object, SignalHandler>>{};
+
+  // A table mapping (signal) -> signal handler list. The list is in the order
+  // that the signal handlers should be run.
+  final Map<ProcessSignal, List<SignalHandler>> _handlersList =
+      <ProcessSignal, List<SignalHandler>>{};
+
+  // A table mapping (signal) -> low-level signal event stream.
+  final Map<ProcessSignal, StreamSubscription<ProcessSignal>>
+      _streamSubscriptions =
+      <ProcessSignal, StreamSubscription<ProcessSignal>>{};
+
+  // The stream controller for errors coming from signal handlers.
+  final StreamController<Object> _errorStreamController =
+      StreamController<Object>.broadcast();
+
+  @override
+  Stream<Object> get errors => _errorStreamController.stream;
+
+  @override
+  Object addHandler(ProcessSignal signal, SignalHandler handler) {
+    final Object token = Object();
+    _handlersTable.putIfAbsent(signal, () => <Object, SignalHandler>{});
+    _handlersTable[signal]![token] = handler;
+
+    _handlersList.putIfAbsent(signal, () => <SignalHandler>[]);
+    _handlersList[signal]!.add(handler);
+
+    // If we added the first one, then call signal.watch(), listen, and cache
+    // the stream controller.
+    if (_handlersList[signal]!.length == 1) {
+      _streamSubscriptions[signal] = signal.watch().listen(
+        _handleSignal,
+        onError: (Object e) {
+          _handlersTable[signal]?.remove(token);
+          _handlersList[signal]?.remove(handler);
+        },
+      );
+    }
+    return token;
+  }
+
+  @override
+  Future<bool> removeHandler(ProcessSignal signal, Object token) async {
+    // We don't know about this signal.
+    if (!_handlersTable.containsKey(signal)) {
+      return false;
+    }
+    // We don't know about this token.
+    if (!_handlersTable[signal]!.containsKey(token)) {
+      return false;
+    }
+    final SignalHandler? handler = _handlersTable[signal]!.remove(token);
+    if (handler == null) {
+      return false;
+    }
+    final bool removed = _handlersList[signal]!.remove(handler);
+    if (!removed) {
+      return false;
+    }
+
+    // If _handlersList[signal] is empty, then lookup the cached stream
+    // controller and unsubscribe from the stream.
+    if (_handlersList.isEmpty) {
+      await _streamSubscriptions[signal]?.cancel();
+    }
+    return true;
+  }
+
+  Future<void> _handleSignal(ProcessSignal s) async {
+    final List<SignalHandler>? handlers = _handlersList[s];
+    if (handlers != null) {
+      final List<SignalHandler> handlersCopy = handlers.toList();
+      for (final SignalHandler handler in handlersCopy) {
+        try {
+          await asyncGuard<void>(() async => handler(s));
+        } on Exception catch (e) {
+          if (_errorStreamController.hasListener) {
+            _errorStreamController.add(e);
+          }
+        }
+      }
+    }
+    // If this was a signal that should cause the process to go down, then
+    // call exit();
+    if (exitSignals.contains(s)) {
+      exit(0);
+    }
+  }
+}
diff --git a/packages/flutter_migrate/lib/src/base/terminal.dart b/packages/flutter_migrate/lib/src/base/terminal.dart
new file mode 100644
index 0000000..07976aa
--- /dev/null
+++ b/packages/flutter_migrate/lib/src/base/terminal.dart
@@ -0,0 +1,418 @@
+// Copyright 2013 The Flutter Authors. 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 'common.dart';
+import 'io.dart' as io;
+import 'logger.dart';
+
+enum TerminalColor {
+  red,
+  green,
+  blue,
+  cyan,
+  yellow,
+  magenta,
+  grey,
+}
+
+/// A class that contains the context settings for command text output to the
+/// console.
+class OutputPreferences {
+  OutputPreferences({
+    bool? wrapText,
+    int? wrapColumn,
+    bool? showColor,
+    io.Stdio? stdio,
+  })  : _stdio = stdio,
+        wrapText = wrapText ?? stdio?.hasTerminal ?? false,
+        _overrideWrapColumn = wrapColumn,
+        showColor = showColor ?? false;
+
+  /// A version of this class for use in tests.
+  OutputPreferences.test(
+      {this.wrapText = false,
+      int wrapColumn = kDefaultTerminalColumns,
+      this.showColor = false})
+      : _overrideWrapColumn = wrapColumn,
+        _stdio = null;
+
+  final io.Stdio? _stdio;
+
+  /// If [wrapText] is true, then any text sent to the context's [Logger]
+  /// instance (e.g. from the [printError] or [printStatus] functions) will be
+  /// wrapped (newlines added between words) to be no longer than the
+  /// [wrapColumn] specifies. Defaults to true if there is a terminal. To
+  /// determine if there's a terminal, [OutputPreferences] asks the context's
+  /// stdio.
+  final bool wrapText;
+
+  /// The terminal width used by the [wrapText] function if there is no terminal
+  /// attached to [io.Stdio], --wrap is on, and --wrap-columns was not specified.
+  static const int kDefaultTerminalColumns = 100;
+
+  /// The column at which output sent to the context's [Logger] instance
+  /// (e.g. from the [printError] or [printStatus] functions) will be wrapped.
+  /// Ignored if [wrapText] is false. Defaults to the width of the output
+  /// terminal, or to [kDefaultTerminalColumns] if not writing to a terminal.
+  final int? _overrideWrapColumn;
+  int get wrapColumn {
+    return _overrideWrapColumn ??
+        _stdio?.terminalColumns ??
+        kDefaultTerminalColumns;
+  }
+
+  /// Whether or not to output ANSI color codes when writing to the output
+  /// terminal. Defaults to whatever [platform.stdoutSupportsAnsi] says if
+  /// writing to a terminal, and false otherwise.
+  final bool showColor;
+
+  @override
+  String toString() {
+    return '$runtimeType[wrapText: $wrapText, wrapColumn: $wrapColumn, showColor: $showColor]';
+  }
+}
+
+/// The command line terminal, if available.
+abstract class Terminal {
+  /// Create a new test [Terminal].
+  ///
+  /// If not specified, [supportsColor] defaults to `false`.
+  factory Terminal.test({bool supportsColor, bool supportsEmoji}) =
+      _TestTerminal;
+
+  /// Whether the current terminal supports color escape codes.
+  bool get supportsColor;
+
+  /// Whether the current terminal can display emoji.
+  bool get supportsEmoji;
+
+  /// When we have a choice of styles (e.g. animated spinners), this selects the
+  /// style to use.
+  int get preferredStyle;
+
+  /// Whether we are interacting with the flutter tool via the terminal.
+  ///
+  /// If not set, defaults to false.
+  bool get usesTerminalUi;
+  set usesTerminalUi(bool value);
+
+  /// Whether there is a terminal attached to stdin.
+  ///
+  /// If true, this usually indicates that a user is using the CLI as
+  /// opposed to using an IDE. This can be used to determine
+  /// whether it is appropriate to show a terminal prompt,
+  /// or whether an automatic selection should be made instead.
+  bool get stdinHasTerminal;
+
+  /// Warning mark to use in stdout or stderr.
+  String get warningMark;
+
+  /// Success mark to use in stdout.
+  String get successMark;
+
+  String bolden(String message);
+
+  String color(String message, TerminalColor color);
+
+  String clearScreen();
+
+  bool get singleCharMode;
+  set singleCharMode(bool value);
+
+  /// Return keystrokes from the console.
+  ///
+  /// This is a single-subscription stream. This stream may be closed before
+  /// the application exits.
+  ///
+  /// Useful when the console is in [singleCharMode].
+  Stream<String> get keystrokes;
+
+  /// Prompts the user to input a character within a given list. Re-prompts if
+  /// entered character is not in the list.
+  ///
+  /// The `prompt`, if non-null, is the text displayed prior to waiting for user
+  /// input each time. If `prompt` is non-null and `displayAcceptedCharacters`
+  /// is true, the accepted keys are printed next to the `prompt`.
+  ///
+  /// The returned value is the user's input; if `defaultChoiceIndex` is not
+  /// null, and the user presses enter without any other input, the return value
+  /// will be the character in `acceptedCharacters` at the index given by
+  /// `defaultChoiceIndex`.
+  ///
+  /// The accepted characters must be a String with a length of 1, excluding any
+  /// whitespace characters such as `\t`, `\n`, or ` `.
+  ///
+  /// If [usesTerminalUi] is false, throws a [StateError].
+  Future<String> promptForCharInput(
+    List<String> acceptedCharacters, {
+    required Logger logger,
+    String? prompt,
+    int? defaultChoiceIndex,
+    bool displayAcceptedCharacters = true,
+  });
+}
+
+class AnsiTerminal implements Terminal {
+  AnsiTerminal({
+    required io.Stdio stdio,
+    DateTime?
+        now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00.
+    bool? supportsColor,
+  })  : _stdio = stdio,
+        _now = now ?? DateTime(1),
+        _supportsColor = supportsColor;
+
+  final io.Stdio _stdio;
+  final DateTime _now;
+
+  static const String bold = '\u001B[1m';
+  static const String resetAll = '\u001B[0m';
+  static const String resetColor = '\u001B[39m';
+  static const String resetBold = '\u001B[22m';
+  static const String clear = '\u001B[2J\u001B[H';
+
+  static const String red = '\u001b[31m';
+  static const String green = '\u001b[32m';
+  static const String blue = '\u001b[34m';
+  static const String cyan = '\u001b[36m';
+  static const String magenta = '\u001b[35m';
+  static const String yellow = '\u001b[33m';
+  static const String grey = '\u001b[90m';
+
+  static const Map<TerminalColor, String> _colorMap = <TerminalColor, String>{
+    TerminalColor.red: red,
+    TerminalColor.green: green,
+    TerminalColor.blue: blue,
+    TerminalColor.cyan: cyan,
+    TerminalColor.magenta: magenta,
+    TerminalColor.yellow: yellow,
+    TerminalColor.grey: grey,
+  };
+
+  static String colorCode(TerminalColor color) => _colorMap[color]!;
+
+  @override
+  bool get supportsColor => _supportsColor ?? stdout.supportsAnsiEscapes;
+  final bool? _supportsColor;
+
+  // Assume unicode emojis are supported when not on Windows.
+  // If we are on Windows, unicode emojis are supported in Windows Terminal,
+  // which sets the WT_SESSION environment variable. See:
+  // https://github.com/microsoft/terminal/blob/master/doc/user-docs/index.md#tips-and-tricks
+  @override
+  bool get supportsEmoji =>
+      !isWindows || Platform.environment.containsKey('WT_SESSION');
+
+  @override
+  int get preferredStyle {
+    const int workdays = DateTime.friday;
+    if (_now.weekday <= workdays) {
+      return _now.weekday - 1;
+    }
+    return _now.hour + workdays;
+  }
+
+  final RegExp _boldControls = RegExp(
+    '(${RegExp.escape(resetBold)}|${RegExp.escape(bold)})',
+  );
+
+  @override
+  bool usesTerminalUi = false;
+
+  @override
+  String get warningMark {
+    return bolden(color('[!]', TerminalColor.red));
+  }
+
+  @override
+  String get successMark {
+    return bolden(color('✓', TerminalColor.green));
+  }
+
+  @override
+  String bolden(String message) {
+    assert(message != null);
+    if (!supportsColor || message.isEmpty) {
+      return message;
+    }
+    final StringBuffer buffer = StringBuffer();
+    for (String line in message.split('\n')) {
+      // If there were bolds or resetBolds in the string before, then nuke them:
+      // they're redundant. This prevents previously embedded resets from
+      // stopping the boldness.
+      line = line.replaceAll(_boldControls, '');
+      buffer.writeln('$bold$line$resetBold');
+    }
+    final String result = buffer.toString();
+    // avoid introducing a new newline to the emboldened text
+    return (!message.endsWith('\n') && result.endsWith('\n'))
+        ? result.substring(0, result.length - 1)
+        : result;
+  }
+
+  @override
+  String color(String message, TerminalColor color) {
+    assert(message != null);
+    if (!supportsColor || color == null || message.isEmpty) {
+      return message;
+    }
+    final StringBuffer buffer = StringBuffer();
+    final String colorCodes = _colorMap[color]!;
+    for (String line in message.split('\n')) {
+      // If there were resets in the string before, then keep them, but
+      // restart the color right after. This prevents embedded resets from
+      // stopping the colors, and allows nesting of colors.
+      line = line.replaceAll(resetColor, '$resetColor$colorCodes');
+      buffer.writeln('$colorCodes$line$resetColor');
+    }
+    final String result = buffer.toString();
+    // avoid introducing a new newline to the colored text
+    return (!message.endsWith('\n') && result.endsWith('\n'))
+        ? result.substring(0, result.length - 1)
+        : result;
+  }
+
+  @override
+  String clearScreen() => supportsColor ? clear : '\n\n';
+
+  @override
+  bool get singleCharMode {
+    if (!_stdio.stdinHasTerminal) {
+      return false;
+    }
+    final io.Stdin stdin = _stdio.stdin as io.Stdin;
+    return stdin.lineMode && stdin.echoMode;
+  }
+
+  @override
+  set singleCharMode(bool value) {
+    if (!_stdio.stdinHasTerminal) {
+      return;
+    }
+    final io.Stdin stdin = _stdio.stdin as io.Stdin;
+    // The order of setting lineMode and echoMode is important on Windows.
+    if (value) {
+      stdin.echoMode = false;
+      stdin.lineMode = false;
+    } else {
+      stdin.lineMode = true;
+      stdin.echoMode = true;
+    }
+  }
+
+  @override
+  bool get stdinHasTerminal => _stdio.stdinHasTerminal;
+
+  Stream<String>? _broadcastStdInString;
+
+  @override
+  Stream<String> get keystrokes {
+    return _broadcastStdInString ??= _stdio.stdin
+        .transform<String>(const AsciiDecoder(allowInvalid: true))
+        .asBroadcastStream();
+  }
+
+  @override
+  Future<String> promptForCharInput(
+    List<String> acceptedCharacters, {
+    required Logger logger,
+    String? prompt,
+    int? defaultChoiceIndex,
+    bool displayAcceptedCharacters = true,
+  }) async {
+    assert(acceptedCharacters.isNotEmpty);
+    assert(prompt == null || prompt.isNotEmpty);
+    if (!usesTerminalUi) {
+      throw StateError('cannot prompt without a terminal ui');
+    }
+    List<String> charactersToDisplay = acceptedCharacters;
+    if (defaultChoiceIndex != null) {
+      assert(defaultChoiceIndex >= 0 &&
+          defaultChoiceIndex < acceptedCharacters.length);
+      charactersToDisplay = List<String>.of(charactersToDisplay);
+      charactersToDisplay[defaultChoiceIndex] =
+          bolden(charactersToDisplay[defaultChoiceIndex]);
+      acceptedCharacters.add('');
+    }
+    String? choice;
+    singleCharMode = true;
+    while (choice == null ||
+        choice.length > 1 ||
+        !acceptedCharacters.contains(choice)) {
+      if (prompt != null) {
+        logger.printStatus(prompt, emphasis: true, newline: false);
+        if (displayAcceptedCharacters) {
+          logger.printStatus(' [${charactersToDisplay.join("|")}]',
+              newline: false);
+        }
+        // prompt ends with ': '
+        logger.printStatus(': ', emphasis: true, newline: false);
+      }
+      choice = (await keystrokes.first).trim();
+      logger.printStatus(choice);
+    }
+    singleCharMode = false;
+    if (defaultChoiceIndex != null && choice == '') {
+      choice = acceptedCharacters[defaultChoiceIndex];
+    }
+    return choice;
+  }
+}
+
+class _TestTerminal implements Terminal {
+  _TestTerminal({this.supportsColor = false, this.supportsEmoji = false});
+
+  @override
+  bool usesTerminalUi = false;
+
+  @override
+  String bolden(String message) => message;
+
+  @override
+  String clearScreen() => '\n\n';
+
+  @override
+  String color(String message, TerminalColor color) => message;
+
+  @override
+  Stream<String> get keystrokes => const Stream<String>.empty();
+
+  @override
+  Future<String> promptForCharInput(
+    List<String> acceptedCharacters, {
+    required Logger logger,
+    String? prompt,
+    int? defaultChoiceIndex,
+    bool displayAcceptedCharacters = true,
+  }) {
+    throw UnsupportedError(
+        'promptForCharInput not supported in the test terminal.');
+  }
+
+  @override
+  bool get singleCharMode => false;
+  @override
+  set singleCharMode(bool value) {}
+
+  @override
+  final bool supportsColor;
+
+  @override
+  final bool supportsEmoji;
+
+  @override
+  int get preferredStyle => 0;
+
+  @override
+  bool get stdinHasTerminal => false;
+
+  @override
+  String get successMark => '✓';
+
+  @override
+  String get warningMark => '[!]';
+}
diff --git a/packages/flutter_migrate/pubspec.yaml b/packages/flutter_migrate/pubspec.yaml
index 28f180b..3704d5b 100644
--- a/packages/flutter_migrate/pubspec.yaml
+++ b/packages/flutter_migrate/pubspec.yaml
@@ -10,5 +10,20 @@
 
 dependencies:
   args: ^2.3.1
+  convert: 3.0.2
+  file: 6.1.4
+  intl: 0.17.0
+  meta: 1.8.0
+  path: ^1.8.0
+  process: 4.2.4
+  test_api: 0.4.13
+  test_core: 0.4.17
+  vm_service: 9.3.0
+  xml: ^6.1.0
+  yaml: 3.1.1
 
 dev_dependencies:
+  collection: 1.16.0
+  file_testing: ^3.0.0
+  lints: ^2.0.0
+  test: ^1.16.0
diff --git a/packages/flutter_migrate/test/base/context_test.dart b/packages/flutter_migrate/test/base/context_test.dart
new file mode 100644
index 0000000..53f8e8f
--- /dev/null
+++ b/packages/flutter_migrate/test/base/context_test.dart
@@ -0,0 +1,296 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+
+import 'package:flutter_migrate/src/base/context.dart';
+
+import '../src/common.dart';
+
+void main() {
+  group('AppContext', () {
+    group('global getter', () {
+      late bool called;
+
+      setUp(() {
+        called = false;
+      });
+
+      test('returns non-null context in the root zone', () {
+        expect(context, isNotNull);
+      });
+
+      test(
+          'returns root context in child of root zone if zone was manually created',
+          () {
+        final Zone rootZone = Zone.current;
+        final AppContext rootContext = context;
+        runZoned<void>(() {
+          expect(Zone.current, isNot(rootZone));
+          expect(Zone.current.parent, rootZone);
+          expect(context, rootContext);
+          called = true;
+        });
+        expect(called, isTrue);
+      });
+
+      test('returns child context after run', () async {
+        final AppContext rootContext = context;
+        await rootContext.run<void>(
+            name: 'child',
+            body: () {
+              expect(context, isNot(rootContext));
+              expect(context.name, 'child');
+              called = true;
+            });
+        expect(called, isTrue);
+      });
+
+      test('returns grandchild context after nested run', () async {
+        final AppContext rootContext = context;
+        await rootContext.run<void>(
+            name: 'child',
+            body: () async {
+              final AppContext childContext = context;
+              await childContext.run<void>(
+                  name: 'grandchild',
+                  body: () {
+                    expect(context, isNot(rootContext));
+                    expect(context, isNot(childContext));
+                    expect(context.name, 'grandchild');
+                    called = true;
+                  });
+            });
+        expect(called, isTrue);
+      });
+
+      test('scans up zone hierarchy for first context', () async {
+        final AppContext rootContext = context;
+        await rootContext.run<void>(
+            name: 'child',
+            body: () {
+              final AppContext childContext = context;
+              runZoned<void>(() {
+                expect(context, isNot(rootContext));
+                expect(context, same(childContext));
+                expect(context.name, 'child');
+                called = true;
+              });
+            });
+        expect(called, isTrue);
+      });
+    });
+
+    group('operator[]', () {
+      test('still finds values if async code runs after body has finished',
+          () async {
+        final Completer<void> outer = Completer<void>();
+        final Completer<void> inner = Completer<void>();
+        String? value;
+        await context.run<void>(
+          body: () {
+            outer.future.then<void>((_) {
+              value = context.get<String>();
+              inner.complete();
+            });
+          },
+          fallbacks: <Type, Generator>{
+            String: () => 'value',
+          },
+        );
+        expect(value, isNull);
+        outer.complete();
+        await inner.future;
+        expect(value, 'value');
+      });
+
+      test('caches generated override values', () async {
+        int consultationCount = 0;
+        String? value;
+        await context.run<void>(
+          body: () async {
+            final StringBuffer buf = StringBuffer(context.get<String>()!);
+            buf.write(context.get<String>());
+            await context.run<void>(body: () {
+              buf.write(context.get<String>());
+            });
+            value = buf.toString();
+          },
+          overrides: <Type, Generator>{
+            String: () {
+              consultationCount++;
+              return 'v';
+            },
+          },
+        );
+        expect(value, 'vvv');
+        expect(consultationCount, 1);
+      });
+
+      test('caches generated fallback values', () async {
+        int consultationCount = 0;
+        String? value;
+        await context.run(
+          body: () async {
+            final StringBuffer buf = StringBuffer(context.get<String>()!);
+            buf.write(context.get<String>());
+            await context.run<void>(body: () {
+              buf.write(context.get<String>());
+            });
+            value = buf.toString();
+          },
+          fallbacks: <Type, Generator>{
+            String: () {
+              consultationCount++;
+              return 'v';
+            },
+          },
+        );
+        expect(value, 'vvv');
+        expect(consultationCount, 1);
+      });
+
+      test('returns null if generated value is null', () async {
+        final String? value = await context.run<String?>(
+          body: () => context.get<String>(),
+          overrides: <Type, Generator>{
+            String: () => null,
+          },
+        );
+        expect(value, isNull);
+      });
+
+      test('throws if generator has dependency cycle', () async {
+        final Future<String?> value = context.run<String?>(
+          body: () async {
+            return context.get<String>();
+          },
+          fallbacks: <Type, Generator>{
+            int: () => int.parse(context.get<String>() ?? ''),
+            String: () => '${context.get<double>()}',
+            double: () => context.get<int>()! * 1.0,
+          },
+        );
+        expect(
+          () => value,
+          throwsA(
+            isA<ContextDependencyCycleException>().having(
+                (ContextDependencyCycleException error) => error.cycle,
+                'cycle',
+                <Type>[String, double, int]).having(
+              (ContextDependencyCycleException error) => error.toString(),
+              'toString()',
+              'Dependency cycle detected: String -> double -> int',
+            ),
+          ),
+        );
+      });
+    });
+
+    group('run', () {
+      test('returns the value returned by body', () async {
+        expect(await context.run<int>(body: () => 123), 123);
+        expect(await context.run<String>(body: () => 'value'), 'value');
+        expect(await context.run<int>(body: () async => 456), 456);
+      });
+
+      test('passes name to child context', () async {
+        await context.run<void>(
+            name: 'child',
+            body: () {
+              expect(context.name, 'child');
+            });
+      });
+
+      group('fallbacks', () {
+        late bool called;
+
+        setUp(() {
+          called = false;
+        });
+
+        test('are applied after parent context is consulted', () async {
+          final String? value = await context.run<String?>(
+            body: () {
+              return context.run<String?>(
+                body: () {
+                  called = true;
+                  return context.get<String>();
+                },
+                fallbacks: <Type, Generator>{
+                  String: () => 'child',
+                },
+              );
+            },
+          );
+          expect(called, isTrue);
+          expect(value, 'child');
+        });
+
+        test('are not applied if parent context supplies value', () async {
+          bool childConsulted = false;
+          final String? value = await context.run<String?>(
+            body: () {
+              return context.run<String?>(
+                body: () {
+                  called = true;
+                  return context.get<String>();
+                },
+                fallbacks: <Type, Generator>{
+                  String: () {
+                    childConsulted = true;
+                    return 'child';
+                  },
+                },
+              );
+            },
+            fallbacks: <Type, Generator>{
+              String: () => 'parent',
+            },
+          );
+          expect(called, isTrue);
+          expect(value, 'parent');
+          expect(childConsulted, isFalse);
+        });
+
+        test('may depend on one another', () async {
+          final String? value = await context.run<String?>(
+            body: () {
+              return context.get<String>();
+            },
+            fallbacks: <Type, Generator>{
+              int: () => 123,
+              String: () => '-${context.get<int>()}-',
+            },
+          );
+          expect(value, '-123-');
+        });
+      });
+
+      group('overrides', () {
+        test('intercept consultation of parent context', () async {
+          bool parentConsulted = false;
+          final String? value = await context.run<String?>(
+            body: () {
+              return context.run<String?>(
+                body: () => context.get<String>(),
+                overrides: <Type, Generator>{
+                  String: () => 'child',
+                },
+              );
+            },
+            fallbacks: <Type, Generator>{
+              String: () {
+                parentConsulted = true;
+                return 'parent';
+              },
+            },
+          );
+          expect(value, 'child');
+          expect(parentConsulted, isFalse);
+        });
+      });
+    });
+  });
+}
diff --git a/packages/flutter_migrate/test/base/file_system_test.dart b/packages/flutter_migrate/test/base/file_system_test.dart
new file mode 100644
index 0000000..b7a6424
--- /dev/null
+++ b/packages/flutter_migrate/test/base/file_system_test.dart
@@ -0,0 +1,357 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io' as io;
+
+import 'package:file/memory.dart';
+import 'package:file_testing/file_testing.dart';
+import 'package:flutter_migrate/src/base/common.dart';
+import 'package:flutter_migrate/src/base/file_system.dart';
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/logger.dart';
+import 'package:flutter_migrate/src/base/signals.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+
+class LocalFileSystemFake extends LocalFileSystem {
+  LocalFileSystemFake.test({required super.signals}) : super.test();
+
+  @override
+  Directory get superSystemTempDirectory => directory('/does_not_exist');
+}
+
+void main() {
+  group('fsUtils', () {
+    late MemoryFileSystem fs;
+    late FileSystemUtils fsUtils;
+
+    setUp(() {
+      fs = MemoryFileSystem.test();
+      fsUtils = FileSystemUtils(
+        fileSystem: fs,
+      );
+    });
+
+    testWithoutContext('getUniqueFile creates a unique file name', () async {
+      final File fileA = fsUtils.getUniqueFile(
+          fs.currentDirectory, 'foo', 'json')
+        ..createSync();
+      final File fileB =
+          fsUtils.getUniqueFile(fs.currentDirectory, 'foo', 'json');
+
+      expect(fileA.path, '/foo_01.json');
+      expect(fileB.path, '/foo_02.json');
+    });
+
+    testWithoutContext('getUniqueDirectory creates a unique directory name',
+        () async {
+      final Directory directoryA =
+          fsUtils.getUniqueDirectory(fs.currentDirectory, 'foo')..createSync();
+      final Directory directoryB =
+          fsUtils.getUniqueDirectory(fs.currentDirectory, 'foo');
+
+      expect(directoryA.path, '/foo_01');
+      expect(directoryB.path, '/foo_02');
+    });
+  });
+
+  group('copyDirectorySync', () {
+    /// Test file_systems.copyDirectorySync() using MemoryFileSystem.
+    /// Copies between 2 instances of file systems which is also supported by copyDirectorySync().
+    testWithoutContext('test directory copy', () async {
+      final MemoryFileSystem sourceMemoryFs = MemoryFileSystem.test();
+      const String sourcePath = '/some/origin';
+      final Directory sourceDirectory =
+          await sourceMemoryFs.directory(sourcePath).create(recursive: true);
+      sourceMemoryFs.currentDirectory = sourcePath;
+      final File sourceFile1 = sourceMemoryFs.file('some_file.txt')
+        ..writeAsStringSync('bleh');
+      final DateTime writeTime = sourceFile1.lastModifiedSync();
+      sourceMemoryFs
+          .file('sub_dir/another_file.txt')
+          .createSync(recursive: true);
+      sourceMemoryFs.directory('empty_directory').createSync();
+
+      // Copy to another memory file system instance.
+      final MemoryFileSystem targetMemoryFs = MemoryFileSystem.test();
+      const String targetPath = '/some/non-existent/target';
+      final Directory targetDirectory = targetMemoryFs.directory(targetPath);
+
+      copyDirectory(sourceDirectory, targetDirectory);
+
+      expect(targetDirectory.existsSync(), true);
+      targetMemoryFs.currentDirectory = targetPath;
+      expect(targetMemoryFs.directory('empty_directory').existsSync(), true);
+      expect(
+          targetMemoryFs.file('sub_dir/another_file.txt').existsSync(), true);
+      expect(targetMemoryFs.file('some_file.txt').readAsStringSync(), 'bleh');
+
+      // Assert that the copy operation hasn't modified the original file in some way.
+      expect(
+          sourceMemoryFs.file('some_file.txt').lastModifiedSync(), writeTime);
+      // There's still 3 things in the original directory as there were initially.
+      expect(sourceMemoryFs.directory(sourcePath).listSync().length, 3);
+    });
+
+    testWithoutContext('Skip files if shouldCopyFile returns false', () {
+      final MemoryFileSystem fileSystem = MemoryFileSystem.test();
+      final Directory origin = fileSystem.directory('/origin');
+      origin.createSync();
+      fileSystem
+          .file(fileSystem.path.join('origin', 'a.txt'))
+          .writeAsStringSync('irrelevant');
+      fileSystem.directory('/origin/nested').createSync();
+      fileSystem
+          .file(fileSystem.path.join('origin', 'nested', 'a.txt'))
+          .writeAsStringSync('irrelevant');
+      fileSystem
+          .file(fileSystem.path.join('origin', 'nested', 'b.txt'))
+          .writeAsStringSync('irrelevant');
+
+      final Directory destination = fileSystem.directory('/destination');
+      copyDirectory(origin, destination,
+          shouldCopyFile: (File origin, File dest) {
+        return origin.basename == 'b.txt';
+      });
+
+      expect(destination.existsSync(), isTrue);
+      expect(destination.childDirectory('nested').existsSync(), isTrue);
+      expect(
+          destination.childDirectory('nested').childFile('b.txt').existsSync(),
+          isTrue);
+
+      expect(destination.childFile('a.txt').existsSync(), isFalse);
+      expect(
+          destination.childDirectory('nested').childFile('a.txt').existsSync(),
+          isFalse);
+    });
+
+    testWithoutContext('Skip directories if shouldCopyDirectory returns false',
+        () {
+      final MemoryFileSystem fileSystem = MemoryFileSystem.test();
+      final Directory origin = fileSystem.directory('/origin');
+      origin.createSync();
+      fileSystem
+          .file(fileSystem.path.join('origin', 'a.txt'))
+          .writeAsStringSync('irrelevant');
+      fileSystem.directory('/origin/nested').createSync();
+      fileSystem
+          .file(fileSystem.path.join('origin', 'nested', 'a.txt'))
+          .writeAsStringSync('irrelevant');
+      fileSystem
+          .file(fileSystem.path.join('origin', 'nested', 'b.txt'))
+          .writeAsStringSync('irrelevant');
+
+      final Directory destination = fileSystem.directory('/destination');
+      copyDirectory(origin, destination,
+          shouldCopyDirectory: (Directory directory) {
+        return !directory.path.endsWith('nested');
+      });
+
+      expect(destination, exists);
+      expect(destination.childDirectory('nested'), isNot(exists));
+      expect(destination.childDirectory('nested').childFile('b.txt'),
+          isNot(exists));
+    });
+  });
+
+  group('LocalFileSystem', () {
+    late FakeProcessSignal fakeSignal;
+    late ProcessSignal signalUnderTest;
+
+    setUp(() {
+      fakeSignal = FakeProcessSignal();
+      signalUnderTest = ProcessSignal(fakeSignal);
+    });
+
+    testWithoutContext('runs shutdown hooks', () async {
+      final Signals signals = Signals.test();
+      final LocalFileSystem localFileSystem = LocalFileSystem.test(
+        signals: signals,
+      );
+      final Directory temp = localFileSystem.systemTempDirectory;
+
+      expect(temp.existsSync(), isTrue);
+      expect(localFileSystem.shutdownHooks.registeredHooks, hasLength(1));
+      final BufferLogger logger = BufferLogger.test();
+      await localFileSystem.shutdownHooks.runShutdownHooks(logger);
+      expect(temp.existsSync(), isFalse);
+      expect(logger.traceText, contains('Running 1 shutdown hook'));
+    });
+
+    testWithoutContext('deletes system temp entry on a fatal signal', () async {
+      final Completer<void> completer = Completer<void>();
+      final Signals signals = Signals.test();
+      final LocalFileSystem localFileSystem = LocalFileSystem.test(
+        signals: signals,
+        fatalSignals: <ProcessSignal>[signalUnderTest],
+      );
+      final Directory temp = localFileSystem.systemTempDirectory;
+
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        completer.complete();
+      });
+
+      expect(temp.existsSync(), isTrue);
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+
+      expect(temp.existsSync(), isFalse);
+    });
+
+    testWithoutContext('throwToolExit when temp not found', () async {
+      final Signals signals = Signals.test();
+      final LocalFileSystemFake localFileSystem = LocalFileSystemFake.test(
+        signals: signals,
+      );
+
+      try {
+        localFileSystem.systemTempDirectory;
+        fail('expected tool exit');
+      } on ToolExit catch (e) {
+        expect(
+            e.message,
+            'Your system temp directory (/does_not_exist) does not exist. '
+            'Did you set an invalid override in your environment? '
+            'See issue https://github.com/flutter/flutter/issues/74042 for more context.');
+      }
+    });
+  });
+}
+
+class FakeProcessSignal extends Fake implements io.ProcessSignal {
+  final StreamController<io.ProcessSignal> controller =
+      StreamController<io.ProcessSignal>();
+
+  @override
+  Stream<io.ProcessSignal> watch() => controller.stream;
+}
+
+/// Various convenience file system methods.
+class FileSystemUtils {
+  FileSystemUtils({
+    required FileSystem fileSystem,
+  }) : _fileSystem = fileSystem;
+
+  final FileSystem _fileSystem;
+
+  /// Appends a number to a filename in order to make it unique under a
+  /// directory.
+  File getUniqueFile(Directory dir, String baseName, String ext) {
+    final FileSystem fs = dir.fileSystem;
+    int i = 1;
+
+    while (true) {
+      final String name = '${baseName}_${i.toString().padLeft(2, '0')}.$ext';
+      final File file = fs.file(dir.fileSystem.path.join(dir.path, name));
+      if (!file.existsSync()) {
+        file.createSync(recursive: true);
+        return file;
+      }
+      i += 1;
+    }
+  }
+
+  // /// Appends a number to a filename in order to make it unique under a
+  // /// directory.
+  // File getUniqueFile(Directory dir, String baseName, String ext) {
+  //   return _getUniqueFile(dir, baseName, ext);
+  // }
+
+  /// Appends a number to a directory name in order to make it unique under a
+  /// directory.
+  Directory getUniqueDirectory(Directory dir, String baseName) {
+    final FileSystem fs = dir.fileSystem;
+    int i = 1;
+
+    while (true) {
+      final String name = '${baseName}_${i.toString().padLeft(2, '0')}';
+      final Directory directory =
+          fs.directory(_fileSystem.path.join(dir.path, name));
+      if (!directory.existsSync()) {
+        return directory;
+      }
+      i += 1;
+    }
+  }
+
+  /// Escapes [path].
+  ///
+  /// On Windows it replaces all '\' with '\\'. On other platforms, it returns the
+  /// path unchanged.
+  String escapePath(String path) =>
+      isWindows ? path.replaceAll(r'\', r'\\') : path;
+
+  /// Returns true if the file system [entity] has not been modified since the
+  /// latest modification to [referenceFile].
+  ///
+  /// Returns true, if [entity] does not exist.
+  ///
+  /// Returns false, if [entity] exists, but [referenceFile] does not.
+  bool isOlderThanReference({
+    required FileSystemEntity entity,
+    required File referenceFile,
+  }) {
+    if (!entity.existsSync()) {
+      return true;
+    }
+    return referenceFile.existsSync() &&
+        referenceFile.statSync().modified.isAfter(entity.statSync().modified);
+  }
+}
+
+/// Creates `destDir` if needed, then recursively copies `srcDir` to
+/// `destDir`, invoking [onFileCopied], if specified, for each
+/// source/destination file pair.
+///
+/// Skips files if [shouldCopyFile] returns `false`.
+/// Does not recurse over directories if [shouldCopyDirectory] returns `false`.
+void copyDirectory(
+  Directory srcDir,
+  Directory destDir, {
+  bool Function(File srcFile, File destFile)? shouldCopyFile,
+  bool Function(Directory)? shouldCopyDirectory,
+  void Function(File srcFile, File destFile)? onFileCopied,
+}) {
+  if (!srcDir.existsSync()) {
+    throw Exception(
+        'Source directory "${srcDir.path}" does not exist, nothing to copy');
+  }
+
+  if (!destDir.existsSync()) {
+    destDir.createSync(recursive: true);
+  }
+
+  for (final FileSystemEntity entity in srcDir.listSync()) {
+    final String newPath =
+        destDir.fileSystem.path.join(destDir.path, entity.basename);
+    if (entity is Link) {
+      final Link newLink = destDir.fileSystem.link(newPath);
+      newLink.createSync(entity.targetSync());
+    } else if (entity is File) {
+      final File newFile = destDir.fileSystem.file(newPath);
+      if (shouldCopyFile != null && !shouldCopyFile(entity, newFile)) {
+        continue;
+      }
+      newFile.writeAsBytesSync(entity.readAsBytesSync());
+      onFileCopied?.call(entity, newFile);
+    } else if (entity is Directory) {
+      if (shouldCopyDirectory != null && !shouldCopyDirectory(entity)) {
+        continue;
+      }
+      copyDirectory(
+        entity,
+        destDir.fileSystem.directory(newPath),
+        shouldCopyFile: shouldCopyFile,
+        onFileCopied: onFileCopied,
+      );
+    } else {
+      throw Exception(
+          '${entity.path} is neither File nor Directory, was ${entity.runtimeType}');
+    }
+  }
+}
diff --git a/packages/flutter_migrate/test/base/io_test.dart b/packages/flutter_migrate/test/base/io_test.dart
new file mode 100644
index 0000000..608715e
--- /dev/null
+++ b/packages/flutter_migrate/test/base/io_test.dart
@@ -0,0 +1,86 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io' as io;
+
+import 'package:file/memory.dart';
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+import '../src/io.dart';
+
+void main() {
+  testWithoutContext('IOOverrides can inject a memory file system', () async {
+    final MemoryFileSystem memoryFileSystem = MemoryFileSystem.test();
+    final FlutterIOOverrides flutterIOOverrides =
+        FlutterIOOverrides(fileSystem: memoryFileSystem);
+    await io.IOOverrides.runWithIOOverrides(() async {
+      // statics delegate correctly.
+      expect(io.FileSystemEntity.isWatchSupported,
+          memoryFileSystem.isWatchSupported);
+      expect(io.Directory.systemTemp.path,
+          memoryFileSystem.systemTempDirectory.path);
+
+      // can create and write to files/directories sync.
+      final io.File file = io.File('abc');
+      file.writeAsStringSync('def');
+      final io.Directory directory = io.Directory('foobar');
+      directory.createSync();
+
+      expect(memoryFileSystem.file('abc').existsSync(), true);
+      expect(memoryFileSystem.file('abc').readAsStringSync(), 'def');
+      expect(memoryFileSystem.directory('foobar').existsSync(), true);
+
+      // can create and write to files/directories async.
+      final io.File fileB = io.File('xyz');
+      await fileB.writeAsString('def');
+      final io.Directory directoryB = io.Directory('barfoo');
+      await directoryB.create();
+
+      expect(memoryFileSystem.file('xyz').existsSync(), true);
+      expect(memoryFileSystem.file('xyz').readAsStringSync(), 'def');
+      expect(memoryFileSystem.directory('barfoo').existsSync(), true);
+
+      // Links
+      final io.Link linkA = io.Link('hhh');
+      final io.Link linkB = io.Link('ggg');
+      io.File('jjj').createSync();
+      io.File('lll').createSync();
+      await linkA.create('jjj');
+      linkB.createSync('lll');
+
+      expect(await memoryFileSystem.link('hhh').resolveSymbolicLinks(),
+          await linkA.resolveSymbolicLinks());
+      expect(memoryFileSystem.link('ggg').resolveSymbolicLinksSync(),
+          linkB.resolveSymbolicLinksSync());
+    }, flutterIOOverrides);
+  });
+
+  testWithoutContext('ProcessSignal signals are properly delegated', () async {
+    final FakeProcessSignal signal = FakeProcessSignal();
+    final ProcessSignal signalUnderTest = ProcessSignal(signal);
+
+    signal.controller.add(signal);
+
+    expect(signalUnderTest, await signalUnderTest.watch().first);
+  });
+
+  testWithoutContext('ProcessSignal toString() works', () async {
+    expect(io.ProcessSignal.sigint.toString(), ProcessSignal.sigint.toString());
+  });
+
+  testWithoutContext('test_api defines the Declarer in a known place', () {
+    expect(Zone.current[#test.declarer], isNotNull);
+  });
+}
+
+class FakeProcessSignal extends Fake implements io.ProcessSignal {
+  final StreamController<io.ProcessSignal> controller =
+      StreamController<io.ProcessSignal>();
+
+  @override
+  Stream<io.ProcessSignal> watch() => controller.stream;
+}
diff --git a/packages/flutter_migrate/test/base/logger_test.dart b/packages/flutter_migrate/test/base/logger_test.dart
new file mode 100644
index 0000000..da05522
--- /dev/null
+++ b/packages/flutter_migrate/test/base/logger_test.dart
@@ -0,0 +1,679 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/logger.dart';
+import 'package:flutter_migrate/src/base/terminal.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+import '../src/fakes.dart';
+
+// final Platform _kNoAnsiPlatform = FakePlatform();
+final String red = RegExp.escape(AnsiTerminal.red);
+final String bold = RegExp.escape(AnsiTerminal.bold);
+final String resetBold = RegExp.escape(AnsiTerminal.resetBold);
+final String resetColor = RegExp.escape(AnsiTerminal.resetColor);
+
+void main() {
+  testWithoutContext('correct logger instance is created', () {
+    final LoggerFactory loggerFactory = LoggerFactory(
+      terminal: Terminal.test(),
+      stdio: FakeStdio(),
+      outputPreferences: OutputPreferences.test(),
+    );
+
+    expect(
+        loggerFactory.createLogger(
+          windows: false,
+        ),
+        isA<StdoutLogger>());
+    expect(
+        loggerFactory.createLogger(
+          windows: true,
+        ),
+        isA<WindowsStdoutLogger>());
+  });
+
+  testWithoutContext(
+      'WindowsStdoutLogger rewrites emojis when terminal does not support emoji',
+      () {
+    final FakeStdio stdio = FakeStdio();
+    final WindowsStdoutLogger logger = WindowsStdoutLogger(
+      outputPreferences: OutputPreferences.test(),
+      stdio: stdio,
+      terminal: Terminal.test(supportsColor: false, supportsEmoji: false),
+    );
+
+    logger.printStatus('🔥🖼️✗✓🔨💪✏️');
+
+    expect(stdio.writtenToStdout, <String>['X√\n']);
+  });
+
+  testWithoutContext(
+      'WindowsStdoutLogger does not rewrite emojis when terminal does support emoji',
+      () {
+    final FakeStdio stdio = FakeStdio();
+    final WindowsStdoutLogger logger = WindowsStdoutLogger(
+      outputPreferences: OutputPreferences.test(),
+      stdio: stdio,
+      terminal: Terminal.test(supportsColor: true, supportsEmoji: true),
+    );
+
+    logger.printStatus('🔥🖼️✗✓🔨💪✏️');
+
+    expect(stdio.writtenToStdout, <String>['🔥🖼️✗✓🔨💪✏️\n']);
+  });
+  testWithoutContext(
+      'Logger does not throw when stdio write throws synchronously', () async {
+    final FakeStdout stdout = FakeStdout(syncError: true);
+    final FakeStdout stderr = FakeStdout(syncError: true);
+    final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr);
+    final Logger logger = StdoutLogger(
+      terminal: AnsiTerminal(
+        stdio: stdio,
+      ),
+      stdio: stdio,
+      outputPreferences: OutputPreferences.test(),
+    );
+
+    logger.printStatus('message');
+    logger.printError('error message');
+  });
+
+  testWithoutContext(
+      'Logger does not throw when stdio write throws asynchronously', () async {
+    final FakeStdout stdout = FakeStdout(syncError: false);
+    final FakeStdout stderr = FakeStdout(syncError: false);
+    final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr);
+    final Logger logger = StdoutLogger(
+      terminal: AnsiTerminal(
+        stdio: stdio,
+      ),
+      stdio: stdio,
+      outputPreferences: OutputPreferences.test(),
+    );
+    logger.printStatus('message');
+    logger.printError('error message');
+
+    await stdout.done;
+    await stderr.done;
+  });
+
+  testWithoutContext(
+      'Logger does not throw when stdio completes done with an error',
+      () async {
+    final FakeStdout stdout =
+        FakeStdout(syncError: false, completeWithError: true);
+    final FakeStdout stderr =
+        FakeStdout(syncError: false, completeWithError: true);
+    final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr);
+    final Logger logger = StdoutLogger(
+      terminal: AnsiTerminal(
+        stdio: stdio,
+      ),
+      stdio: stdio,
+      outputPreferences: OutputPreferences.test(),
+    );
+    logger.printStatus('message');
+    logger.printError('error message');
+
+    expect(() async => stdout.done, throwsException);
+    expect(() async => stderr.done, throwsException);
+  });
+
+  group('Output format', () {
+    late FakeStdio fakeStdio;
+    late SummaryStatus summaryStatus;
+    late int called;
+
+    setUp(() {
+      fakeStdio = FakeStdio();
+      called = 0;
+      summaryStatus = SummaryStatus(
+        message: 'Hello world',
+        padding: 20,
+        onFinish: () => called++,
+        stdio: fakeStdio,
+        stopwatch: FakeStopwatch(),
+      );
+    });
+
+    List<String> outputStdout() => fakeStdio.writtenToStdout.join().split('\n');
+    List<String> outputStderr() => fakeStdio.writtenToStderr.join().split('\n');
+
+    testWithoutContext('Error logs are wrapped', () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printError('0123456789' * 15);
+      final List<String> lines = outputStderr();
+
+      expect(outputStdout().length, equals(1));
+      expect(outputStdout().first, isEmpty);
+      expect(lines[0], equals('0123456789' * 4));
+      expect(lines[1], equals('0123456789' * 4));
+      expect(lines[2], equals('0123456789' * 4));
+      expect(lines[3], equals('0123456789' * 3));
+    });
+
+    testWithoutContext('Error logs are wrapped and can be indented.', () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printError('0123456789' * 15, indent: 5);
+      final List<String> lines = outputStderr();
+
+      expect(outputStdout().length, equals(1));
+      expect(outputStdout().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('     01234567890123456789012345678901234'));
+      expect(lines[1], equals('     56789012345678901234567890123456789'));
+      expect(lines[2], equals('     01234567890123456789012345678901234'));
+      expect(lines[3], equals('     56789012345678901234567890123456789'));
+      expect(lines[4], equals('     0123456789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext('Error logs are wrapped and can have hanging indent.',
+        () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printError('0123456789' * 15, hangingIndent: 5);
+      final List<String> lines = outputStderr();
+
+      expect(outputStdout().length, equals(1));
+      expect(outputStdout().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('0123456789012345678901234567890123456789'));
+      expect(lines[1], equals('     01234567890123456789012345678901234'));
+      expect(lines[2], equals('     56789012345678901234567890123456789'));
+      expect(lines[3], equals('     01234567890123456789012345678901234'));
+      expect(lines[4], equals('     56789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext(
+        'Error logs are wrapped, indented, and can have hanging indent.',
+        () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printError('0123456789' * 15, indent: 4, hangingIndent: 5);
+      final List<String> lines = outputStderr();
+
+      expect(outputStdout().length, equals(1));
+      expect(outputStdout().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('    012345678901234567890123456789012345'));
+      expect(lines[1], equals('         6789012345678901234567890123456'));
+      expect(lines[2], equals('         7890123456789012345678901234567'));
+      expect(lines[3], equals('         8901234567890123456789012345678'));
+      expect(lines[4], equals('         901234567890123456789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext('Stdout logs are wrapped', () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printStatus('0123456789' * 15);
+      final List<String> lines = outputStdout();
+
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      expect(lines[0], equals('0123456789' * 4));
+      expect(lines[1], equals('0123456789' * 4));
+      expect(lines[2], equals('0123456789' * 4));
+      expect(lines[3], equals('0123456789' * 3));
+    });
+
+    testWithoutContext('Stdout logs are wrapped and can be indented.',
+        () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printStatus('0123456789' * 15, indent: 5);
+      final List<String> lines = outputStdout();
+
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('     01234567890123456789012345678901234'));
+      expect(lines[1], equals('     56789012345678901234567890123456789'));
+      expect(lines[2], equals('     01234567890123456789012345678901234'));
+      expect(lines[3], equals('     56789012345678901234567890123456789'));
+      expect(lines[4], equals('     0123456789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext('Stdout logs are wrapped and can have hanging indent.',
+        () async {
+      final Logger logger = StdoutLogger(
+          terminal: AnsiTerminal(
+            stdio: fakeStdio,
+          ),
+          stdio: fakeStdio,
+          outputPreferences:
+              OutputPreferences.test(wrapText: true, wrapColumn: 40));
+      logger.printStatus('0123456789' * 15, hangingIndent: 5);
+      final List<String> lines = outputStdout();
+
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('0123456789012345678901234567890123456789'));
+      expect(lines[1], equals('     01234567890123456789012345678901234'));
+      expect(lines[2], equals('     56789012345678901234567890123456789'));
+      expect(lines[3], equals('     01234567890123456789012345678901234'));
+      expect(lines[4], equals('     56789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext(
+        'Stdout logs are wrapped, indented, and can have hanging indent.',
+        () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+      );
+      logger.printStatus('0123456789' * 15, indent: 4, hangingIndent: 5);
+      final List<String> lines = outputStdout();
+
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      expect(lines.length, equals(6));
+      expect(lines[0], equals('    012345678901234567890123456789012345'));
+      expect(lines[1], equals('         6789012345678901234567890123456'));
+      expect(lines[2], equals('         7890123456789012345678901234567'));
+      expect(lines[3], equals('         8901234567890123456789012345678'));
+      expect(lines[4], equals('         901234567890123456789'));
+      expect(lines[5], isEmpty);
+    });
+
+    testWithoutContext('Error logs are red', () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+          supportsColor: true,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      logger.printError('Pants on fire!');
+      final List<String> lines = outputStderr();
+
+      expect(outputStdout().length, equals(1));
+      expect(outputStdout().first, isEmpty);
+      expect(
+          lines[0],
+          equals(
+              '${AnsiTerminal.red}Pants on fire!${AnsiTerminal.resetColor}'));
+    });
+
+    testWithoutContext('Stdout logs are not colored', () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      logger.printStatus('All good.');
+
+      final List<String> lines = outputStdout();
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      expect(lines[0], equals('All good.'));
+    });
+
+    testWithoutContext('Stdout printBox puts content inside a box', () {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      logger.printBox('Hello world', title: 'Test title');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test title ┐\n'
+            '│ Hello world │\n'
+            '└─────────────┘\n'),
+      );
+    });
+
+    testWithoutContext('Stdout printBox does not require title', () {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      logger.printBox('Hello world');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      expect(
+        stdout,
+        contains('\n'
+            '┌─────────────┐\n'
+            '│ Hello world │\n'
+            '└─────────────┘\n'),
+      );
+    });
+
+    testWithoutContext('Stdout printBox handles new lines', () {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      logger.printBox('Hello world\nThis is a new line', title: 'Test title');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test title ───────┐\n'
+            '│ Hello world        │\n'
+            '│ This is a new line │\n'
+            '└────────────────────┘\n'),
+      );
+    });
+
+    testWithoutContext(
+        'Stdout printBox handles content with ANSI escape characters', () {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(showColor: true),
+      );
+      const String bold = '\u001B[1m';
+      const String clear = '\u001B[2J\u001B[H';
+      logger.printBox('${bold}Hello world$clear', title: 'Test title');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test title ┐\n'
+            '│ ${bold}Hello world$clear │\n'
+            '└─────────────┘\n'),
+      );
+    });
+
+    testWithoutContext('Stdout printBox handles column limit', () {
+      const int columnLimit = 14;
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
+      );
+      logger.printBox('This line is longer than $columnLimit characters',
+          title: 'Test');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      final List<String> stdoutLines = stdout.split('\n');
+
+      expect(stdoutLines.length, greaterThan(1));
+      expect(stdoutLines[1].length, equals(columnLimit));
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test ─────┐\n'
+            '│ This line  │\n'
+            '│ is longer  │\n'
+            '│ than 14    │\n'
+            '│ characters │\n'
+            '└────────────┘\n'),
+      );
+    });
+
+    testWithoutContext(
+        'Stdout printBox handles column limit and respects new lines', () {
+      const int columnLimit = 14;
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
+      );
+      logger.printBox('This\nline is longer than\n\n$columnLimit characters',
+          title: 'Test');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      final List<String> stdoutLines = stdout.split('\n');
+
+      expect(stdoutLines.length, greaterThan(1));
+      expect(stdoutLines[1].length, equals(columnLimit));
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test ─────┐\n'
+            '│ This       │\n'
+            '│ line is    │\n'
+            '│ longer     │\n'
+            '│ than       │\n'
+            '│            │\n'
+            '│ 14         │\n'
+            '│ characters │\n'
+            '└────────────┘\n'),
+      );
+    });
+
+    testWithoutContext(
+        'Stdout printBox breaks long words that exceed the column limit', () {
+      const int columnLimit = 14;
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences:
+            OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
+      );
+      logger.printBox('Thiswordislongerthan${columnLimit}characters',
+          title: 'Test');
+      final String stdout = fakeStdio.writtenToStdout.join();
+      final List<String> stdoutLines = stdout.split('\n');
+
+      expect(stdoutLines.length, greaterThan(1));
+      expect(stdoutLines[1].length, equals(columnLimit));
+      expect(
+        stdout,
+        contains('\n'
+            '┌─ Test ─────┐\n'
+            '│ Thiswordis │\n'
+            '│ longerthan │\n'
+            '│ 14characte │\n'
+            '│ rs         │\n'
+            '└────────────┘\n'),
+      );
+    });
+
+    testWithoutContext('Stdout startProgress on non-color terminal', () async {
+      final FakeStopwatch fakeStopwatch = FakeStopwatch();
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(),
+        stopwatchFactory: FakeStopwatchFactory(stopwatch: fakeStopwatch),
+      );
+      final Status status = logger.startProgress(
+        'Hello',
+        progressIndicatorPadding:
+            20, // this minus the "Hello" equals the 15 below.
+      );
+      expect(outputStderr().length, equals(1));
+      expect(outputStderr().first, isEmpty);
+      // the 5 below is the margin that is always included between the message and the time.
+      expect(outputStdout().join('\n'), matches(r'^Hello {15} {5}$'));
+
+      fakeStopwatch.elapsed = const Duration(seconds: 4, milliseconds: 123);
+      status.stop();
+
+      expect(outputStdout(), <String>['Hello                        4.1s', '']);
+    });
+
+    testWithoutContext('SummaryStatus works when canceled', () async {
+      final SummaryStatus summaryStatus = SummaryStatus(
+        message: 'Hello world',
+        padding: 20,
+        onFinish: () => called++,
+        stdio: fakeStdio,
+        stopwatch: FakeStopwatch(),
+      );
+      summaryStatus.start();
+      final List<String> lines = outputStdout();
+      expect(lines[0], startsWith('Hello world              '));
+      expect(lines.length, equals(1));
+      expect(lines[0].endsWith('\n'), isFalse);
+
+      // Verify a cancel does _not_ print the time and prints a newline.
+      summaryStatus.cancel();
+      expect(outputStdout(), <String>[
+        'Hello world              ',
+        '',
+      ]);
+
+      // Verify that stopping or canceling multiple times throws.
+      expect(summaryStatus.cancel, throwsAssertionError);
+      expect(summaryStatus.stop, throwsAssertionError);
+    });
+
+    testWithoutContext('SummaryStatus works when stopped', () async {
+      summaryStatus.start();
+      final List<String> lines = outputStdout();
+      expect(lines[0], startsWith('Hello world              '));
+      expect(lines.length, equals(1));
+
+      // Verify a stop prints the time.
+      summaryStatus.stop();
+      expect(outputStdout(), <String>[
+        'Hello world                   0ms',
+        '',
+      ]);
+
+      // Verify that stopping or canceling multiple times throws.
+      expect(summaryStatus.stop, throwsAssertionError);
+      expect(summaryStatus.cancel, throwsAssertionError);
+    });
+
+    testWithoutContext('sequential startProgress calls with StdoutLogger',
+        () async {
+      final Logger logger = StdoutLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        stdio: fakeStdio,
+        outputPreferences: OutputPreferences.test(),
+      );
+      logger.startProgress('AAA').stop();
+      logger.startProgress('BBB').stop();
+      final List<String> output = outputStdout();
+
+      expect(output.length, equals(3));
+
+      // There's 61 spaces at the start: 59 (padding default) - 3 (length of AAA) + 5 (margin).
+      // Then there's a left-padded "0ms" 8 characters wide, so 5 spaces then "0ms"
+      // (except sometimes it's randomly slow so we handle up to "99,999ms").
+      expect(output[0], matches(RegExp(r'AAA[ ]{61}[\d, ]{5}[\d]ms')));
+      expect(output[1], matches(RegExp(r'BBB[ ]{61}[\d, ]{5}[\d]ms')));
+    });
+
+    testWithoutContext('sequential startProgress calls with BufferLogger',
+        () async {
+      final BufferLogger logger = BufferLogger(
+        terminal: AnsiTerminal(
+          stdio: fakeStdio,
+        ),
+        outputPreferences: OutputPreferences.test(),
+      );
+      logger.startProgress('AAA').stop();
+      logger.startProgress('BBB').stop();
+
+      expect(logger.statusText, 'AAA\nBBB\n');
+    });
+  });
+}
+
+/// A fake [Logger] that throws the [Invocation] for any method call.
+class FakeLogger implements Logger {
+  @override
+  dynamic noSuchMethod(Invocation invocation) =>
+      throw invocation; // ignore: only_throw_errors
+}
+
+class FakeStdout extends Fake implements Stdout {
+  FakeStdout({required this.syncError, this.completeWithError = false});
+
+  final bool syncError;
+  final bool completeWithError;
+  final Completer<void> _completer = Completer<void>();
+
+  @override
+  void write(Object? object) {
+    if (syncError) {
+      throw Exception('Error!');
+    }
+    Zone.current.runUnaryGuarded<void>((_) {
+      if (completeWithError) {
+        _completer.completeError(Exception('Some pipe error'));
+      } else {
+        _completer.complete();
+        throw Exception('Error!');
+      }
+    }, null);
+  }
+
+  @override
+  Future<void> get done => _completer.future;
+}
diff --git a/packages/flutter_migrate/test/base/signals_test.dart b/packages/flutter_migrate/test/base/signals_test.dart
new file mode 100644
index 0000000..87f0d04
--- /dev/null
+++ b/packages/flutter_migrate/test/base/signals_test.dart
@@ -0,0 +1,181 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io' as io;
+
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/signals.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+
+void main() {
+  group('Signals', () {
+    late Signals signals;
+    late FakeProcessSignal fakeSignal;
+    late ProcessSignal signalUnderTest;
+
+    setUp(() {
+      signals = Signals.test();
+      fakeSignal = FakeProcessSignal();
+      signalUnderTest = ProcessSignal(fakeSignal);
+    });
+
+    testWithoutContext('signal handler runs', () async {
+      final Completer<void> completer = Completer<void>();
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        expect(s, signalUnderTest);
+        completer.complete();
+      });
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+    });
+
+    testWithoutContext('signal handlers run in order', () async {
+      final Completer<void> completer = Completer<void>();
+
+      bool first = false;
+
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        expect(s, signalUnderTest);
+        first = true;
+      });
+
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        expect(s, signalUnderTest);
+        expect(first, isTrue);
+        completer.complete();
+      });
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+    });
+
+    testWithoutContext(
+        'signal handlers do not cause concurrent modification errors when removing handlers in a signal callback',
+        () async {
+      final Completer<void> completer = Completer<void>();
+      late Object token;
+      Future<void> handle(ProcessSignal s) async {
+        expect(s, signalUnderTest);
+        expect(await signals.removeHandler(signalUnderTest, token), true);
+        completer.complete();
+      }
+
+      token = signals.addHandler(signalUnderTest, handle);
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+    });
+
+    testWithoutContext('signal handler error goes on error stream', () async {
+      final Exception exn = Exception('Error');
+      signals.addHandler(signalUnderTest, (ProcessSignal s) async {
+        throw exn;
+      });
+
+      final Completer<void> completer = Completer<void>();
+      final List<Object> errList = <Object>[];
+      final StreamSubscription<Object> errSub = signals.errors.listen(
+        (Object err) {
+          errList.add(err);
+          completer.complete();
+        },
+      );
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+      await errSub.cancel();
+      expect(errList, contains(exn));
+    });
+
+    testWithoutContext('removed signal handler does not run', () async {
+      final Object token = signals.addHandler(
+        signalUnderTest,
+        (ProcessSignal s) async {
+          fail('Signal handler should have been removed.');
+        },
+      );
+
+      await signals.removeHandler(signalUnderTest, token);
+
+      final List<Object> errList = <Object>[];
+      final StreamSubscription<Object> errSub = signals.errors.listen(
+        (Object err) {
+          errList.add(err);
+        },
+      );
+
+      fakeSignal.controller.add(fakeSignal);
+
+      await errSub.cancel();
+      expect(errList, isEmpty);
+    });
+
+    testWithoutContext('non-removed signal handler still runs', () async {
+      final Completer<void> completer = Completer<void>();
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        expect(s, signalUnderTest);
+        completer.complete();
+      });
+
+      final Object token = signals.addHandler(
+        signalUnderTest,
+        (ProcessSignal s) async {
+          fail('Signal handler should have been removed.');
+        },
+      );
+      await signals.removeHandler(signalUnderTest, token);
+
+      final List<Object> errList = <Object>[];
+      final StreamSubscription<Object> errSub = signals.errors.listen(
+        (Object err) {
+          errList.add(err);
+        },
+      );
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+      await errSub.cancel();
+      expect(errList, isEmpty);
+    });
+
+    testWithoutContext('only handlers for the correct signal run', () async {
+      final FakeProcessSignal mockSignal2 = FakeProcessSignal();
+      final ProcessSignal otherSignal = ProcessSignal(mockSignal2);
+
+      final Completer<void> completer = Completer<void>();
+      signals.addHandler(signalUnderTest, (ProcessSignal s) {
+        expect(s, signalUnderTest);
+        completer.complete();
+      });
+
+      signals.addHandler(otherSignal, (ProcessSignal s) async {
+        fail('Wrong signal!.');
+      });
+
+      final List<Object> errList = <Object>[];
+      final StreamSubscription<Object> errSub = signals.errors.listen(
+        (Object err) {
+          errList.add(err);
+        },
+      );
+
+      fakeSignal.controller.add(fakeSignal);
+      await completer.future;
+      await errSub.cancel();
+      expect(errList, isEmpty);
+    });
+  });
+}
+
+class FakeProcessSignal extends Fake implements io.ProcessSignal {
+  final StreamController<io.ProcessSignal> controller =
+      StreamController<io.ProcessSignal>();
+
+  @override
+  Stream<io.ProcessSignal> watch() => controller.stream;
+}
diff --git a/packages/flutter_migrate/test/base/terminal_test.dart b/packages/flutter_migrate/test/base/terminal_test.dart
new file mode 100644
index 0000000..6686ec2
--- /dev/null
+++ b/packages/flutter_migrate/test/base/terminal_test.dart
@@ -0,0 +1,331 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/logger.dart';
+import 'package:flutter_migrate/src/base/terminal.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+
+void main() {
+  group('output preferences', () {
+    testWithoutContext('can wrap output', () async {
+      final BufferLogger bufferLogger = BufferLogger(
+        outputPreferences:
+            OutputPreferences.test(wrapText: true, wrapColumn: 40),
+        terminal: TestTerminal(),
+      );
+      bufferLogger.printStatus('0123456789' * 8);
+
+      expect(bufferLogger.statusText, equals(('${'0123456789' * 4}\n') * 2));
+    });
+
+    testWithoutContext('can turn off wrapping', () async {
+      final BufferLogger bufferLogger = BufferLogger(
+        outputPreferences: OutputPreferences.test(),
+        terminal: TestTerminal(),
+      );
+      final String testString = '0123456789' * 20;
+      bufferLogger.printStatus(testString);
+
+      expect(bufferLogger.statusText, equals('$testString\n'));
+    });
+  });
+
+  group('ANSI coloring and bold', () {
+    late AnsiTerminal terminal;
+
+    setUp(() {
+      terminal = AnsiTerminal(
+        stdio: Stdio(), // Danger, using real stdio.
+        supportsColor: true,
+      );
+    });
+
+    testWithoutContext('adding colors works', () {
+      for (final TerminalColor color in TerminalColor.values) {
+        expect(
+          terminal.color('output', color),
+          equals(
+              '${AnsiTerminal.colorCode(color)}output${AnsiTerminal.resetColor}'),
+        );
+      }
+    });
+
+    testWithoutContext('adding bold works', () {
+      expect(
+        terminal.bolden('output'),
+        equals('${AnsiTerminal.bold}output${AnsiTerminal.resetBold}'),
+      );
+    });
+
+    testWithoutContext('nesting bold within color works', () {
+      expect(
+        terminal.color(terminal.bolden('output'), TerminalColor.blue),
+        equals(
+            '${AnsiTerminal.blue}${AnsiTerminal.bold}output${AnsiTerminal.resetBold}${AnsiTerminal.resetColor}'),
+      );
+      expect(
+        terminal.color('non-bold ${terminal.bolden('output')} also non-bold',
+            TerminalColor.blue),
+        equals(
+            '${AnsiTerminal.blue}non-bold ${AnsiTerminal.bold}output${AnsiTerminal.resetBold} also non-bold${AnsiTerminal.resetColor}'),
+      );
+    });
+
+    testWithoutContext('nesting color within bold works', () {
+      expect(
+        terminal.bolden(terminal.color('output', TerminalColor.blue)),
+        equals(
+            '${AnsiTerminal.bold}${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.resetBold}'),
+      );
+      expect(
+        terminal.bolden(
+            'non-color ${terminal.color('output', TerminalColor.blue)} also non-color'),
+        equals(
+            '${AnsiTerminal.bold}non-color ${AnsiTerminal.blue}output${AnsiTerminal.resetColor} also non-color${AnsiTerminal.resetBold}'),
+      );
+    });
+
+    testWithoutContext('nesting color within color works', () {
+      expect(
+        terminal.color(terminal.color('output', TerminalColor.blue),
+            TerminalColor.magenta),
+        equals(
+            '${AnsiTerminal.magenta}${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.magenta}${AnsiTerminal.resetColor}'),
+      );
+      expect(
+        terminal.color(
+            'magenta ${terminal.color('output', TerminalColor.blue)} also magenta',
+            TerminalColor.magenta),
+        equals(
+            '${AnsiTerminal.magenta}magenta ${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.magenta} also magenta${AnsiTerminal.resetColor}'),
+      );
+    });
+
+    testWithoutContext('nesting bold within bold works', () {
+      expect(
+        terminal.bolden(terminal.bolden('output')),
+        equals('${AnsiTerminal.bold}output${AnsiTerminal.resetBold}'),
+      );
+      expect(
+        terminal.bolden('bold ${terminal.bolden('output')} still bold'),
+        equals(
+            '${AnsiTerminal.bold}bold output still bold${AnsiTerminal.resetBold}'),
+      );
+    });
+  });
+
+  group('character input prompt', () {
+    late AnsiTerminal terminalUnderTest;
+
+    setUp(() {
+      terminalUnderTest = TestTerminal(stdio: FakeStdio());
+    });
+
+    testWithoutContext('character prompt throws if usesTerminalUi is false',
+        () async {
+      expect(
+          terminalUnderTest.promptForCharInput(
+            <String>['a', 'b', 'c'],
+            prompt: 'Please choose something',
+            logger: BufferLogger.test(),
+          ),
+          throwsStateError);
+    });
+
+    testWithoutContext('character prompt', () async {
+      final BufferLogger bufferLogger = BufferLogger(
+        terminal: terminalUnderTest,
+        outputPreferences: OutputPreferences.test(),
+      );
+      terminalUnderTest.usesTerminalUi = true;
+      mockStdInStream = Stream<String>.fromFutures(<Future<String>>[
+        Future<String>.value('d'), // Not in accepted list.
+        Future<String>.value('\n'), // Not in accepted list
+        Future<String>.value('b'),
+      ]).asBroadcastStream();
+      final String choice = await terminalUnderTest.promptForCharInput(
+        <String>['a', 'b', 'c'],
+        prompt: 'Please choose something',
+        logger: bufferLogger,
+      );
+      expect(choice, 'b');
+      expect(
+          bufferLogger.statusText,
+          'Please choose something [a|b|c]: d\n'
+          'Please choose something [a|b|c]: \n'
+          'Please choose something [a|b|c]: b\n');
+    });
+
+    testWithoutContext(
+        'default character choice without displayAcceptedCharacters', () async {
+      final BufferLogger bufferLogger = BufferLogger(
+        terminal: terminalUnderTest,
+        outputPreferences: OutputPreferences.test(),
+      );
+      terminalUnderTest.usesTerminalUi = true;
+      mockStdInStream = Stream<String>.fromFutures(<Future<String>>[
+        Future<String>.value('\n'), // Not in accepted list
+      ]).asBroadcastStream();
+      final String choice = await terminalUnderTest.promptForCharInput(
+        <String>['a', 'b', 'c'],
+        prompt: 'Please choose something',
+        displayAcceptedCharacters: false,
+        defaultChoiceIndex: 1, // which is b.
+        logger: bufferLogger,
+      );
+
+      expect(choice, 'b');
+      expect(bufferLogger.statusText, 'Please choose something: \n');
+    });
+
+    testWithoutContext(
+        'Does not set single char mode when a terminal is not attached', () {
+      final Stdio stdio = FakeStdio()..stdinHasTerminal = false;
+      final AnsiTerminal ansiTerminal = AnsiTerminal(
+        stdio: stdio,
+      );
+
+      expect(() => ansiTerminal.singleCharMode = true, returnsNormally);
+    });
+  });
+
+  testWithoutContext('AnsiTerminal.preferredStyle', () {
+    final Stdio stdio = FakeStdio();
+    expect(AnsiTerminal(stdio: stdio).preferredStyle,
+        0); // Defaults to 0 for backwards compatibility.
+
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018)).preferredStyle, 0);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2)).preferredStyle,
+        1);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3)).preferredStyle,
+        2);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4)).preferredStyle,
+        3);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5)).preferredStyle,
+        4);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6)).preferredStyle,
+        5);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7)).preferredStyle,
+        5);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8)).preferredStyle,
+        0);
+    expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9)).preferredStyle,
+        1);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10)).preferredStyle,
+        2);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11)).preferredStyle,
+        3);
+
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 1, 1)).preferredStyle,
+        0);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2, 1)).preferredStyle,
+        1);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3, 1)).preferredStyle,
+        2);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4, 1)).preferredStyle,
+        3);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5, 1)).preferredStyle,
+        4);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6, 1)).preferredStyle,
+        6);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7, 1)).preferredStyle,
+        6);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8, 1)).preferredStyle,
+        0);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9, 1)).preferredStyle,
+        1);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10, 1))
+            .preferredStyle,
+        2);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11, 1))
+            .preferredStyle,
+        3);
+
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 1, 23))
+            .preferredStyle,
+        0);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2, 23))
+            .preferredStyle,
+        1);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3, 23))
+            .preferredStyle,
+        2);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4, 23))
+            .preferredStyle,
+        3);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5, 23))
+            .preferredStyle,
+        4);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6, 23))
+            .preferredStyle,
+        28);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7, 23))
+            .preferredStyle,
+        28);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8, 23))
+            .preferredStyle,
+        0);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9, 23))
+            .preferredStyle,
+        1);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10, 23))
+            .preferredStyle,
+        2);
+    expect(
+        AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11, 23))
+            .preferredStyle,
+        3);
+  });
+}
+
+late Stream<String> mockStdInStream;
+
+class TestTerminal extends AnsiTerminal {
+  TestTerminal({
+    Stdio? stdio,
+    DateTime? now,
+  }) : super(stdio: stdio ?? Stdio(), now: now ?? DateTime(2018));
+
+  @override
+  Stream<String> get keystrokes {
+    return mockStdInStream;
+  }
+
+  @override
+  bool singleCharMode = false;
+
+  @override
+  int get preferredStyle => 0;
+}
+
+class FakeStdio extends Fake implements Stdio {
+  @override
+  bool stdinHasTerminal = false;
+}
diff --git a/packages/flutter_migrate/test/src/common.dart b/packages/flutter_migrate/test/src/common.dart
new file mode 100644
index 0000000..40e55b0
--- /dev/null
+++ b/packages/flutter_migrate/test/src/common.dart
@@ -0,0 +1,200 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:io' as io;
+
+import 'package:flutter_migrate/src/base/context.dart';
+import 'package:flutter_migrate/src/base/file_system.dart';
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as path; // flutter_ignore: package_path_import
+import 'package:test_api/test_api.dart' // ignore: deprecated_member_use
+    as test_package show test;
+import 'package:test_api/test_api.dart' // ignore: deprecated_member_use
+    hide
+        test;
+
+import 'test_utils.dart';
+
+export 'package:test_api/test_api.dart' // ignore: deprecated_member_use
+    hide
+        isInstanceOf,
+        test;
+
+void tryToDelete(FileSystemEntity fileEntity) {
+  // This should not be necessary, but it turns out that
+  // on Windows it's common for deletions to fail due to
+  // bogus (we think) "access denied" errors.
+  try {
+    if (fileEntity.existsSync()) {
+      fileEntity.deleteSync(recursive: true);
+    }
+  } on FileSystemException catch (error) {
+    // We print this so that it's visible in the logs, to get an idea of how
+    // common this problem is, and if any patterns are ever noticed by anyone.
+    // ignore: avoid_print
+    print('Failed to delete ${fileEntity.path}: $error');
+  }
+}
+
+/// Gets the path to the root of the Flutter repository.
+///
+/// This will first look for a `FLUTTER_ROOT` environment variable. If the
+/// environment variable is set, it will be returned. Otherwise, this will
+/// deduce the path from `platform.script`.
+String getFlutterRoot() {
+  if (io.Platform.environment.containsKey('FLUTTER_ROOT')) {
+    return io.Platform.environment['FLUTTER_ROOT']!;
+  }
+
+  Error invalidScript() => StateError(
+      'Could not determine flutter_tools/ path from script URL (${io.Platform.script}); consider setting FLUTTER_ROOT explicitly.');
+
+  Uri scriptUri;
+  switch (io.Platform.script.scheme) {
+    case 'file':
+      scriptUri = io.Platform.script;
+      break;
+    case 'data':
+      final RegExp flutterTools = RegExp(
+          r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)',
+          multiLine: true);
+      final Match? match =
+          flutterTools.firstMatch(Uri.decodeFull(io.Platform.script.path));
+      if (match == null) {
+        throw invalidScript();
+      }
+      scriptUri = Uri.parse(match.group(1)!);
+      break;
+    default:
+      throw invalidScript();
+  }
+
+  final List<String> parts = path.split(fileSystem.path.fromUri(scriptUri));
+  final int toolsIndex = parts.indexOf('flutter_tools');
+  if (toolsIndex == -1) {
+    throw invalidScript();
+  }
+  final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1));
+  return path.normalize(path.join(toolsPath, '..', '..'));
+}
+
+String getMigratePackageRoot() {
+  return io.Directory.current.path;
+}
+
+String getMigrateMain() {
+  return fileSystem.path
+      .join(getMigratePackageRoot(), 'bin', 'flutter_migrate.dart');
+}
+
+Future<ProcessResult> runMigrateCommand(List<String> args,
+    {String? workingDirectory}) {
+  final List<String> commandArgs = <String>['dart', 'run', getMigrateMain()];
+  commandArgs.addAll(args);
+  return processManager.run(commandArgs, workingDirectory: workingDirectory);
+}
+
+/// The tool overrides `test` to ensure that files created under the
+/// system temporary directory are deleted after each test by calling
+/// `LocalFileSystem.dispose()`.
+@isTest
+void test(
+  String description,
+  FutureOr<void> Function() body, {
+  String? testOn,
+  dynamic skip,
+  List<String>? tags,
+  Map<String, dynamic>? onPlatform,
+  int? retry,
+}) {
+  test_package.test(
+    description,
+    () async {
+      addTearDown(() async {
+        await fileSystem.dispose();
+      });
+
+      return body();
+    },
+    skip: skip,
+    tags: tags,
+    onPlatform: onPlatform,
+    retry: retry,
+    testOn: testOn,
+    // We don't support "timeout"; see ../../dart_test.yaml which
+    // configures all tests to have a 15 minute timeout which should
+    // definitely be enough.
+  );
+}
+
+/// Executes a test body in zone that does not allow context-based injection.
+///
+/// For classes which have been refactored to exclude context-based injection
+/// or globals like [fs] or [platform], prefer using this test method as it
+/// will prevent accidentally including these context getters in future code
+/// changes.
+///
+/// For more information, see https://github.com/flutter/flutter/issues/47161
+@isTest
+void testWithoutContext(
+  String description,
+  FutureOr<void> Function() body, {
+  String? testOn,
+  dynamic skip,
+  List<String>? tags,
+  Map<String, dynamic>? onPlatform,
+  int? retry,
+}) {
+  return test(
+    description,
+    () async {
+      return runZoned(body, zoneValues: <Object, Object>{
+        contextKey: const _NoContext(),
+      });
+    },
+    skip: skip,
+    tags: tags,
+    onPlatform: onPlatform,
+    retry: retry,
+    testOn: testOn,
+    // We don't support "timeout"; see ../../dart_test.yaml which
+    // configures all tests to have a 15 minute timeout which should
+    // definitely be enough.
+  );
+}
+
+/// An implementation of [AppContext] that throws if context.get is called in the test.
+///
+/// The intention of the class is to ensure we do not accidentally regress when
+/// moving towards more explicit dependency injection by accidentally using
+/// a Zone value in place of a constructor parameter.
+class _NoContext implements AppContext {
+  const _NoContext();
+
+  @override
+  T get<T>() {
+    throw UnsupportedError('context.get<$T> is not supported in test methods. '
+        'Use Testbed or testUsingContext if accessing Zone injected '
+        'values.');
+  }
+
+  @override
+  String get name => 'No Context';
+
+  @override
+  Future<V> run<V>({
+    required FutureOr<V> Function() body,
+    String? name,
+    Map<Type, Generator>? overrides,
+    Map<Type, Generator>? fallbacks,
+    ZoneSpecification? zoneSpecification,
+  }) async {
+    return body();
+  }
+}
+
+/// Matcher for functions that throw [AssertionError].
+final Matcher throwsAssertionError = throwsA(isA<AssertionError>());
diff --git a/packages/flutter_migrate/test/src/context.dart b/packages/flutter_migrate/test/src/context.dart
new file mode 100644
index 0000000..4f43b48
--- /dev/null
+++ b/packages/flutter_migrate/test/src/context.dart
@@ -0,0 +1,125 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+
+import 'package:flutter_migrate/src/base/context.dart';
+import 'package:flutter_migrate/src/base/file_system.dart';
+import 'package:flutter_migrate/src/base/logger.dart';
+import 'package:flutter_migrate/src/base/terminal.dart';
+import 'package:meta/meta.dart';
+import 'package:process/process.dart';
+
+import 'common.dart';
+import 'fakes.dart';
+
+/// Return the test logger. This assumes that the current Logger is a BufferLogger.
+BufferLogger get testLogger => context.get<Logger>()! as BufferLogger;
+
+@isTest
+void testUsingContext(
+  String description,
+  dynamic Function() testMethod, {
+  Map<Type, Generator> overrides = const <Type, Generator>{},
+  bool initializeFlutterRoot = true,
+  String? testOn,
+  bool?
+      skip, // should default to `false`, but https://github.com/dart-lang/test/issues/545 doesn't allow this
+}) {
+  if (overrides[FileSystem] != null && overrides[ProcessManager] == null) {
+    throw StateError(
+        'If you override the FileSystem context you must also provide a ProcessManager, '
+        'otherwise the processes you launch will not be dealing with the same file system '
+        'that you are dealing with in your test.');
+  }
+
+  // Ensure we don't rely on the default [Config] constructor which will
+  // leak a sticky $HOME/.flutter_settings behind!
+  Directory? configDir;
+  tearDown(() {
+    if (configDir != null) {
+      tryToDelete(configDir!);
+      configDir = null;
+    }
+  });
+
+  test(description, () async {
+    await runInContext<dynamic>(() {
+      return context.run<dynamic>(
+        name: 'mocks',
+        overrides: <Type, Generator>{
+          AnsiTerminal: () => AnsiTerminal(stdio: FakeStdio()),
+          OutputPreferences: () => OutputPreferences.test(),
+          Logger: () => BufferLogger.test(),
+          ProcessManager: () => const LocalProcessManager(),
+        },
+        body: () {
+          return runZonedGuarded<Future<dynamic>>(() {
+            try {
+              return context.run<dynamic>(
+                // Apply the overrides to the test context in the zone since their
+                // instantiation may reference items already stored on the context.
+                overrides: overrides,
+                name: 'test-specific overrides',
+                body: () async {
+                  if (initializeFlutterRoot) {
+                    // Provide a sane default for the flutterRoot directory. Individual
+                    // tests can override this either in the test or during setup.
+                    // Cache.flutterRoot ??= flutterRoot;
+                  }
+                  return await testMethod();
+                },
+              );
+              // This catch rethrows, so doesn't need to catch only Exception.
+            } catch (error) {
+              // ignore: avoid_catches_without_on_clauses
+              _printBufferedErrors(context);
+              rethrow;
+            }
+          }, (Object error, StackTrace stackTrace) {
+            // When things fail, it's ok to print to the console!
+            print(error); // ignore: avoid_print
+            print(stackTrace); // ignore: avoid_print
+            _printBufferedErrors(context);
+            throw error; //ignore: only_throw_errors
+          });
+        },
+      );
+    }, overrides: <Type, Generator>{});
+  }, testOn: testOn, skip: skip);
+  // We don't support "timeout"; see ../../dart_test.yaml which
+  // configures all tests to have a 15 minute timeout which should
+  // definitely be enough.
+}
+
+void _printBufferedErrors(AppContext testContext) {
+  if (testContext.get<Logger>() is BufferLogger) {
+    final BufferLogger bufferLogger =
+        testContext.get<Logger>()! as BufferLogger;
+    if (bufferLogger.errorText.isNotEmpty) {
+      // This is where the logger outputting errors is implemented, so it has
+      // to use `print`.
+      print(bufferLogger.errorText); // ignore: avoid_print
+    }
+    bufferLogger.clear();
+  }
+}
+
+Future<T> runInContext<T>(
+  FutureOr<T> Function() runner, {
+  Map<Type, Generator>? overrides,
+}) async {
+  // Wrap runner with any asynchronous initialization that should run with the
+  // overrides and callbacks.
+  // late bool runningOnBot;
+  FutureOr<T> runnerWrapper() async {
+    return runner();
+  }
+
+  return context.run<T>(
+      name: 'global fallbacks',
+      body: runnerWrapper,
+      overrides: overrides,
+      fallbacks: <Type, Generator>{});
+}
diff --git a/packages/flutter_migrate/test/src/fakes.dart b/packages/flutter_migrate/test/src/fakes.dart
new file mode 100644
index 0000000..3e6b072
--- /dev/null
+++ b/packages/flutter_migrate/test/src/fakes.dart
@@ -0,0 +1,285 @@
+// Copyright 2013 The Flutter Authors. 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:async';
+import 'dart:convert';
+import 'dart:io' as io show IOSink, Stdout, StdoutException;
+
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/logger.dart';
+import 'package:test/fake.dart';
+
+/// An IOSink that completes a future with the first line written to it.
+class CompleterIOSink extends MemoryIOSink {
+  CompleterIOSink({
+    this.throwOnAdd = false,
+  });
+
+  final bool throwOnAdd;
+
+  final Completer<List<int>> _completer = Completer<List<int>>();
+
+  Future<List<int>> get future => _completer.future;
+
+  @override
+  void add(List<int> data) {
+    if (!_completer.isCompleted) {
+      // When throwOnAdd is true, complete with empty so any expected output
+      // doesn't appear.
+      _completer.complete(throwOnAdd ? <int>[] : data);
+    }
+    if (throwOnAdd) {
+      throw Exception('CompleterIOSink Error');
+    }
+    super.add(data);
+  }
+}
+
+/// An IOSink that collects whatever is written to it.
+class MemoryIOSink implements IOSink {
+  @override
+  Encoding encoding = utf8;
+
+  final List<List<int>> writes = <List<int>>[];
+
+  @override
+  void add(List<int> data) {
+    writes.add(data);
+  }
+
+  @override
+  Future<void> addStream(Stream<List<int>> stream) {
+    final Completer<void> completer = Completer<void>();
+    late StreamSubscription<List<int>> sub;
+    sub = stream.listen(
+      (List<int> data) {
+        try {
+          add(data);
+          // Catches all exceptions to propagate them to the completer.
+        } catch (err, stack) {
+          // ignore: avoid_catches_without_on_clauses
+          sub.cancel();
+          completer.completeError(err, stack);
+        }
+      },
+      onError: completer.completeError,
+      onDone: completer.complete,
+      cancelOnError: true,
+    );
+    return completer.future;
+  }
+
+  @override
+  void writeCharCode(int charCode) {
+    add(<int>[charCode]);
+  }
+
+  @override
+  void write(Object? obj) {
+    add(encoding.encode('$obj'));
+  }
+
+  @override
+  void writeln([Object? obj = '']) {
+    add(encoding.encode('$obj\n'));
+  }
+
+  @override
+  void writeAll(Iterable<dynamic> objects, [String separator = '']) {
+    bool addSeparator = false;
+    for (final dynamic object in objects) {
+      if (addSeparator) {
+        write(separator);
+      }
+      write(object);
+      addSeparator = true;
+    }
+  }
+
+  @override
+  void addError(dynamic error, [StackTrace? stackTrace]) {
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<void> get done => close();
+
+  @override
+  Future<void> close() async {}
+
+  @override
+  Future<void> flush() async {}
+
+  void clear() {
+    writes.clear();
+  }
+
+  String getAndClear() {
+    final String result =
+        utf8.decode(writes.expand((List<int> l) => l).toList());
+    clear();
+    return result;
+  }
+}
+
+class MemoryStdout extends MemoryIOSink implements io.Stdout {
+  @override
+  bool get hasTerminal => _hasTerminal;
+  set hasTerminal(bool value) {
+    assert(value != null);
+    _hasTerminal = value;
+  }
+
+  bool _hasTerminal = true;
+
+  @override
+  io.IOSink get nonBlocking => this;
+
+  @override
+  bool get supportsAnsiEscapes => _supportsAnsiEscapes;
+  set supportsAnsiEscapes(bool value) {
+    assert(value != null);
+    _supportsAnsiEscapes = value;
+  }
+
+  bool _supportsAnsiEscapes = true;
+
+  @override
+  int get terminalColumns {
+    if (_terminalColumns != null) {
+      return _terminalColumns!;
+    }
+    throw const io.StdoutException('unspecified mock value');
+  }
+
+  set terminalColumns(int value) => _terminalColumns = value;
+  int? _terminalColumns;
+
+  @override
+  int get terminalLines {
+    if (_terminalLines != null) {
+      return _terminalLines!;
+    }
+    throw const io.StdoutException('unspecified mock value');
+  }
+
+  set terminalLines(int value) => _terminalLines = value;
+  int? _terminalLines;
+}
+
+/// A Stdio that collects stdout and supports simulated stdin.
+class FakeStdio extends Stdio {
+  final MemoryStdout _stdout = MemoryStdout()..terminalColumns = 80;
+  final MemoryIOSink _stderr = MemoryIOSink();
+  final FakeStdin _stdin = FakeStdin();
+
+  @override
+  MemoryStdout get stdout => _stdout;
+
+  @override
+  MemoryIOSink get stderr => _stderr;
+
+  @override
+  Stream<List<int>> get stdin => _stdin;
+
+  void simulateStdin(String line) {
+    _stdin.controller.add(utf8.encode('$line\n'));
+  }
+
+  @override
+  bool hasTerminal = true;
+
+  List<String> get writtenToStdout =>
+      _stdout.writes.map<String>(_stdout.encoding.decode).toList();
+  List<String> get writtenToStderr =>
+      _stderr.writes.map<String>(_stderr.encoding.decode).toList();
+}
+
+class FakeStdin extends Fake implements Stdin {
+  final StreamController<List<int>> controller = StreamController<List<int>>();
+
+  @override
+  bool echoMode = true;
+
+  @override
+  bool hasTerminal = true;
+
+  @override
+  bool echoNewlineMode = true;
+
+  @override
+  bool lineMode = true;
+
+  @override
+  Stream<S> transform<S>(StreamTransformer<List<int>, S> transformer) {
+    return controller.stream.transform(transformer);
+  }
+
+  @override
+  StreamSubscription<List<int>> listen(
+    void Function(List<int> event)? onData, {
+    Function? onError,
+    void Function()? onDone,
+    bool? cancelOnError,
+  }) {
+    return controller.stream.listen(
+      onData,
+      onError: onError,
+      onDone: onDone,
+      cancelOnError: cancelOnError,
+    );
+  }
+}
+
+class FakeStopwatch implements Stopwatch {
+  @override
+  bool get isRunning => _isRunning;
+  bool _isRunning = false;
+
+  @override
+  void start() => _isRunning = true;
+
+  @override
+  void stop() => _isRunning = false;
+
+  @override
+  Duration elapsed = Duration.zero;
+
+  @override
+  int get elapsedMicroseconds => elapsed.inMicroseconds;
+
+  @override
+  int get elapsedMilliseconds => elapsed.inMilliseconds;
+
+  @override
+  int get elapsedTicks => elapsed.inMilliseconds;
+
+  @override
+  int get frequency => 1000;
+
+  @override
+  void reset() {
+    _isRunning = false;
+    elapsed = Duration.zero;
+  }
+
+  @override
+  String toString() => '$runtimeType $elapsed $isRunning';
+}
+
+class FakeStopwatchFactory implements StopwatchFactory {
+  FakeStopwatchFactory(
+      {Stopwatch? stopwatch, Map<String, Stopwatch>? stopwatches})
+      : stopwatches = <String, Stopwatch>{
+          if (stopwatches != null) ...stopwatches,
+          if (stopwatch != null) '': stopwatch,
+        };
+
+  Map<String, Stopwatch> stopwatches;
+
+  @override
+  Stopwatch createStopwatch([String name = '']) {
+    return stopwatches[name] ?? FakeStopwatch();
+  }
+}
diff --git a/packages/flutter_migrate/test/src/io.dart b/packages/flutter_migrate/test/src/io.dart
new file mode 100644
index 0000000..c7e6bf3
--- /dev/null
+++ b/packages/flutter_migrate/test/src/io.dart
@@ -0,0 +1,138 @@
+// Copyright 2013 The Flutter Authors. 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:io' as io show Directory, File, IOOverrides, Link;
+
+import 'package:flutter_migrate/src/base/file_system.dart';
+
+/// An [IOOverrides] that can delegate to [FileSystem] implementation if provided.
+///
+/// Does not override any of the socket facilities.
+///
+/// Do not provide a [LocalFileSystem] as a delegate. Since internally this calls
+/// out to `dart:io` classes, it will result in a stack overflow error as the
+/// IOOverrides and LocalFileSystem call each other endlessly.
+///
+/// The only safe delegate types are those that do not call out to `dart:io`,
+/// like the [MemoryFileSystem].
+class FlutterIOOverrides extends io.IOOverrides {
+  FlutterIOOverrides({FileSystem? fileSystem})
+      : _fileSystemDelegate = fileSystem;
+
+  final FileSystem? _fileSystemDelegate;
+
+  @override
+  io.Directory createDirectory(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.createDirectory(path);
+    }
+    return _fileSystemDelegate!.directory(path);
+  }
+
+  @override
+  io.File createFile(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.createFile(path);
+    }
+    return _fileSystemDelegate!.file(path);
+  }
+
+  @override
+  io.Link createLink(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.createLink(path);
+    }
+    return _fileSystemDelegate!.link(path);
+  }
+
+  @override
+  Stream<FileSystemEvent> fsWatch(String path, int events, bool recursive) {
+    if (_fileSystemDelegate == null) {
+      return super.fsWatch(path, events, recursive);
+    }
+    return _fileSystemDelegate!
+        .file(path)
+        .watch(events: events, recursive: recursive);
+  }
+
+  @override
+  bool fsWatchIsSupported() {
+    if (_fileSystemDelegate == null) {
+      return super.fsWatchIsSupported();
+    }
+    return _fileSystemDelegate!.isWatchSupported;
+  }
+
+  @override
+  Future<FileSystemEntityType> fseGetType(String path, bool followLinks) {
+    if (_fileSystemDelegate == null) {
+      return super.fseGetType(path, followLinks);
+    }
+    return _fileSystemDelegate!.type(path, followLinks: followLinks);
+  }
+
+  @override
+  FileSystemEntityType fseGetTypeSync(String path, bool followLinks) {
+    if (_fileSystemDelegate == null) {
+      return super.fseGetTypeSync(path, followLinks);
+    }
+    return _fileSystemDelegate!.typeSync(path, followLinks: followLinks);
+  }
+
+  @override
+  Future<bool> fseIdentical(String path1, String path2) {
+    if (_fileSystemDelegate == null) {
+      return super.fseIdentical(path1, path2);
+    }
+    return _fileSystemDelegate!.identical(path1, path2);
+  }
+
+  @override
+  bool fseIdenticalSync(String path1, String path2) {
+    if (_fileSystemDelegate == null) {
+      return super.fseIdenticalSync(path1, path2);
+    }
+    return _fileSystemDelegate!.identicalSync(path1, path2);
+  }
+
+  @override
+  io.Directory getCurrentDirectory() {
+    if (_fileSystemDelegate == null) {
+      return super.getCurrentDirectory();
+    }
+    return _fileSystemDelegate!.currentDirectory;
+  }
+
+  @override
+  io.Directory getSystemTempDirectory() {
+    if (_fileSystemDelegate == null) {
+      return super.getSystemTempDirectory();
+    }
+    return _fileSystemDelegate!.systemTempDirectory;
+  }
+
+  @override
+  void setCurrentDirectory(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.setCurrentDirectory(path);
+    }
+    _fileSystemDelegate!.currentDirectory = path;
+  }
+
+  @override
+  Future<FileStat> stat(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.stat(path);
+    }
+    return _fileSystemDelegate!.stat(path);
+  }
+
+  @override
+  FileStat statSync(String path) {
+    if (_fileSystemDelegate == null) {
+      return super.statSync(path);
+    }
+    return _fileSystemDelegate!.statSync(path);
+  }
+}
diff --git a/packages/flutter_migrate/test/src/test_flutter_command_runner.dart b/packages/flutter_migrate/test/src/test_flutter_command_runner.dart
new file mode 100644
index 0000000..8e4d36b
--- /dev/null
+++ b/packages/flutter_migrate/test/src/test_flutter_command_runner.dart
@@ -0,0 +1,40 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:args/command_runner.dart';
+import 'package:flutter_migrate/src/base/command.dart';
+
+export 'package:test_api/test_api.dart' // ignore: deprecated_member_use
+    hide
+        isInstanceOf,
+        test;
+
+CommandRunner<void> createTestCommandRunner([MigrateCommand? command]) {
+  final CommandRunner<void> runner = TestCommandRunner();
+  if (command != null) {
+    runner.addCommand(command);
+  }
+  return runner;
+}
+
+class TestCommandRunner extends CommandRunner<void> {
+  TestCommandRunner()
+      : super(
+          'flutter',
+          'Manage your Flutter app development.\n'
+              '\n'
+              'Common commands:\n'
+              '\n'
+              '  flutter create <output directory>\n'
+              '    Create a new Flutter project in the specified directory.\n'
+              '\n'
+              '  flutter run [options]\n'
+              '    Run your Flutter application on an attached device or in an emulator.',
+        );
+
+  @override
+  Future<void> run(Iterable<String> args) {
+    return super.run(args);
+  }
+}
diff --git a/packages/flutter_migrate/test/src/test_utils.dart b/packages/flutter_migrate/test/src/test_utils.dart
new file mode 100644
index 0000000..b51e127
--- /dev/null
+++ b/packages/flutter_migrate/test/src/test_utils.dart
@@ -0,0 +1,62 @@
+// Copyright 2013 The Flutter Authors. 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:io';
+
+import 'package:flutter_migrate/src/base/file_system.dart';
+import 'package:flutter_migrate/src/base/io.dart';
+import 'package:flutter_migrate/src/base/signals.dart';
+import 'package:process/process.dart';
+
+import 'common.dart';
+
+/// The [FileSystem] for the integration test environment.
+LocalFileSystem fileSystem =
+    LocalFileSystem.test(signals: LocalSignals.instance);
+
+/// The [ProcessManager] for the integration test environment.
+const ProcessManager processManager = LocalProcessManager();
+
+/// Creates a temporary directory but resolves any symlinks to return the real
+/// underlying path to avoid issues with breakpoints/hot reload.
+/// https://github.com/flutter/flutter/pull/21741
+Directory createResolvedTempDirectorySync(String prefix) {
+  assert(prefix.endsWith('.'));
+  final Directory tempDirectory =
+      fileSystem.systemTempDirectory.createTempSync('flutter_$prefix');
+  return fileSystem.directory(tempDirectory.resolveSymbolicLinksSync());
+}
+
+void writeFile(String path, String content,
+    {bool writeFutureModifiedDate = false}) {
+  final File file = fileSystem.file(path)
+    ..createSync(recursive: true)
+    ..writeAsStringSync(content, flush: true);
+  // Some integration tests on Windows to not see this file as being modified
+  // recently enough for the hot reload to pick this change up unless the
+  // modified time is written in the future.
+  if (writeFutureModifiedDate) {
+    file.setLastModifiedSync(DateTime.now().add(const Duration(seconds: 5)));
+  }
+}
+
+void writePackages(String folder) {
+  writeFile(fileSystem.path.join(folder, '.packages'), '''
+test:${fileSystem.path.join(fileSystem.currentDirectory.path, 'lib')}/
+''');
+}
+
+Future<void> getPackages(String folder) async {
+  final List<String> command = <String>[
+    fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'),
+    'pub',
+    'get',
+  ];
+  final ProcessResult result =
+      await processManager.run(command, workingDirectory: folder);
+  if (result.exitCode != 0) {
+    throw Exception(
+        'flutter pub get failed: ${result.stderr}\n${result.stdout}');
+  }
+}
diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml
index 1ad0f64..a281f3c 100644
--- a/script/configs/custom_analysis.yaml
+++ b/script/configs/custom_analysis.yaml
@@ -8,10 +8,13 @@
 
 # Deliberately uses flutter_lints, as that's what it is demonstrating.
 - flutter_lints/example
+# Adopts some flutter_tools rules regarding public api docs due to being an
+# extension of the tool and using tools base code.
+- flutter_migrate
 # Adds unawaited_futures. We should investigating adding this to the root
 # options instead.
 - metrics_center
 # Has some constructions that are currently handled poorly by dart format.
 - rfw/example
 # Disables docs requirements, as it is test code.
-- web_benchmarks/testing/test_app
+- web_benchmarks/testing/test_app
\ No newline at end of file