Rewrite update_flutter_sdk.sh as a Dart script (#6604)

diff --git a/.github/workflows/flutter-candidate-update.yaml b/.github/workflows/flutter-candidate-update.yaml
index bb9f9ce..37de07e 100644
--- a/.github/workflows/flutter-candidate-update.yaml
+++ b/.github/workflows/flutter-candidate-update.yaml
@@ -3,7 +3,7 @@
   workflow_dispatch: # Allows for manual triggering if needed
   schedule:
     # * is a special character in YAML so you have to quote this string
-    - cron: "0 8 * * *" # Run every day at midnight Pacific Time
+    - cron: "0 8/12 * * *" # Run every day at midnight and noon Pacific Time
 
 permissions:
   contents: write
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cfa97a7..af768dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -84,7 +84,7 @@
 
 ### Workflow for making changes
 
-1. Change your local Flutter SDK to the latest flutter candidate branch: `./tool/update_flutter_sdk.sh --local`
+1. Change your local Flutter SDK to the latest flutter candidate branch: `devtools_tool update-flutter-sdk --local`
 2. Create a branch from your cloned DevTools repo: `git checkout -b myBranch`
 3. Ensure your branch, dependencies, and generated code are up-to-date: `devtools_tool sync`
 4. Implement your changes, and commit to your branch: `git commit -m “description”`
diff --git a/tool/RELEASE_INSTRUCTIONS.md b/tool/RELEASE_INSTRUCTIONS.md
index e4b08a7..3ab8fc0 100644
--- a/tool/RELEASE_INSTRUCTIONS.md
+++ b/tool/RELEASE_INSTRUCTIONS.md
@@ -19,7 +19,7 @@
    c. The local checkout is at `main` branch: `git rebase-update`
 
 2. Your Flutter version is equal to latest candidate release branch:
-    - Run `./tool/update_flutter_sdk.sh --local` from the main devtools directory.
+    - Run `devtools_tool update-flutter-sdk --local`
 3. You have goma [configured](http://go/ma-mac-setup).
 
 ### Prepare the release
diff --git a/tool/build_release.sh b/tool/build_release.sh
index a46415a..c6113e1 100755
--- a/tool/build_release.sh
+++ b/tool/build_release.sh
@@ -26,7 +26,7 @@
   PATH="$FLUTTER_DIR/bin":$PATH
 
   # Make sure the flutter sdk is on the correct branch.
-  ./update_flutter_sdk.sh
+  devtools_tool update-flutter-sdk
 fi
 
 popd
diff --git a/tool/lib/commands/release_helper.dart b/tool/lib/commands/release_helper.dart
index ba01e67..96542bf 100644
--- a/tool/lib/commands/release_helper.dart
+++ b/tool/lib/commands/release_helper.dart
@@ -31,48 +31,20 @@
 
     final useCurrentBranch = argResults!['use-current-branch']!;
     final currentBranchResult = await processManager.runProcess(
-      CliCommand.from(
-        'git',
-        [
-          'rev-parse',
-          '--abbrev-ref',
-          'HEAD',
-        ],
-      ),
+      CliCommand.git('rev-parse --abbrev-ref HEAD'),
     );
     final initialBranch = currentBranchResult.stdout.trim();
     String? releaseBranch;
 
     try {
-      // Change the CWD to the repo root
       Directory.current = pathFromRepoRoot("");
-      print("Finding a remote that points to flutter/devtools.git.");
-      final devtoolsRemotesResult = await processManager.runProcess(
-        CliCommand.from(
-          'git',
-          ['remote', '-v'],
-        ),
+      final remoteUpstream = await findRemote(
+        processManager,
+        remoteId: 'flutter/devtools.git',
       );
-      final String devtoolsRemotes = devtoolsRemotesResult.stdout;
-      final remoteRegexp = RegExp(
-        r'^(?<remote>\S+)\s+(?<path>\S+)\s+\((?<action>\S+)\)',
-        multiLine: true,
-      );
-      final remoteRegexpResults = remoteRegexp.allMatches(devtoolsRemotes);
-      final RegExpMatch devtoolsRemoteResult;
-
-      try {
-        devtoolsRemoteResult = remoteRegexpResults.firstWhere(
-          (element) => RegExp(r'flutter/devtools.git$')
-              .hasMatch(element.namedGroup('path')!),
-        );
-      } on StateError {
-        throw "ERROR: Couldn't find a remote that points to flutter/devtools.git. Instead got: \n$devtoolsRemotes";
-      }
-      final remoteOrigin = devtoolsRemoteResult.namedGroup('remote')!;
 
       final gitStatusResult = await processManager.runProcess(
-        CliCommand.from('git', ['status', '-s']),
+        CliCommand.git('status -s'),
       );
       final gitStatus = gitStatusResult.stdout;
       if (gitStatus.isNotEmpty) {
@@ -85,17 +57,15 @@
       if (!useCurrentBranch) {
         print("Preparing the release branch.");
         await processManager.runProcess(
-          CliCommand.from('git', ['fetch', remoteOrigin, 'master']),
+          CliCommand.git('fetch $remoteUpstream master'),
         );
       }
 
       await processManager.runProcess(
-        CliCommand.from('git', [
-          'checkout',
-          '-b',
-          releaseBranch,
-          ...(useCurrentBranch ? [] : ['$remoteOrigin/master']),
-        ]),
+        CliCommand.git(
+          'checkout -b $releaseBranch'
+          '${useCurrentBranch ? '' : ' $remoteUpstream/master'}',
+        ),
       );
 
       print("Ensuring ./tool packages are ready.");
@@ -131,18 +101,8 @@
 
       await processManager.runAll(
         commands: [
-          CliCommand.from('git', [
-            'commit',
-            '-a',
-            '-m',
-            commitMessage,
-          ]),
-          CliCommand.from('git', [
-            'push',
-            '-u',
-            remoteOrigin,
-            releaseBranch,
-          ]),
+          CliCommand.git('commit -a -m $commitMessage'),
+          CliCommand.git('push -u $remoteUpstream $releaseBranch'),
         ],
       );
 
diff --git a/tool/lib/commands/repo_check.dart b/tool/lib/commands/repo_check.dart
index b22c325..91f7a6c 100644
--- a/tool/lib/commands/repo_check.dart
+++ b/tool/lib/commands/repo_check.dart
@@ -61,14 +61,15 @@
   Future<void> performCheck(DevToolsRepo repo) {
     // TODO(devoncarew): Update this to use a package to parse the pubspec file;
     //                   https://pub.dev/packages/pubspec.
-    final pubspecContents = repo.readFile('packages/devtools_app/pubspec.yaml');
+    final pubspecContents =
+        repo.readFile(Uri.parse('packages/devtools_app/pubspec.yaml'));
     final versionString = pubspecContents
         .split('\n')
         .firstWhere((line) => line.startsWith('version:'));
     final pubspecVersion = versionString.substring('version:'.length).trim();
 
     final dartFileContents =
-        repo.readFile('packages/devtools_app/lib/devtools.dart');
+        repo.readFile(Uri.parse('packages/devtools_app/lib/devtools.dart'));
 
     final regexp = RegExp(r"version = '(\S+)';");
     final match = regexp.firstMatch(dartFileContents);
diff --git a/tool/lib/commands/update_dart_sdk_deps.dart b/tool/lib/commands/update_dart_sdk_deps.dart
index 296ae1b..2dfeaa8 100644
--- a/tool/lib/commands/update_dart_sdk_deps.dart
+++ b/tool/lib/commands/update_dart_sdk_deps.dart
@@ -19,7 +19,7 @@
 /// automatically built and uploaded to CIPD on each DevTools commit.
 ///
 /// To run this script:
-/// `dart run tool/bin/devtools_tool.dart update-sdk-deps -c <commit-hash>`
+/// `devtools_tool update-sdk-deps -c <commit-hash>`
 class UpdateDartSdkDepsCommand extends Command {
   UpdateDartSdkDepsCommand() {
     argParser.addOption(
@@ -49,11 +49,11 @@
       workingDirectory: dartSdkLocation,
       additionalErrorMessage: DartSdkHelper.commandDebugMessage,
       commands: [
-        CliCommand(
-          'git branch -D devtools-$commit',
+        CliCommand.git(
+          'branch -D devtools-$commit',
           throwOnException: false,
         ),
-        CliCommand('git new-branch devtools-$commit'),
+        CliCommand.git('new-branch devtools-$commit'),
       ],
     );
 
@@ -65,7 +65,7 @@
       workingDirectory: dartSdkLocation,
       additionalErrorMessage: DartSdkHelper.commandDebugMessage,
       commands: [
-        CliCommand('git add .'),
+        CliCommand.git('add .'),
         CliCommand.from(
           'git',
           [
@@ -74,7 +74,7 @@
             'Update DevTools rev to $commit',
           ],
         ),
-        CliCommand('git cl upload -s -f'),
+        CliCommand.git('cl upload -s -f'),
       ],
     );
   }
diff --git a/tool/lib/commands/update_flutter_sdk.dart b/tool/lib/commands/update_flutter_sdk.dart
new file mode 100644
index 0000000..d0eb5c2
--- /dev/null
+++ b/tool/lib/commands/update_flutter_sdk.dart
@@ -0,0 +1,167 @@
+// Copyright 2023 The Chromium 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:args/command_runner.dart';
+import 'package:cli_util/cli_logging.dart';
+import 'package:devtools_tool/model.dart';
+import 'package:io/io.dart';
+import 'package:path/path.dart' as path;
+
+import '../utils.dart';
+
+const _localFlag = 'local';
+const _useCacheFlag = 'use-cache';
+
+/// This command updates the the Flutter SDK contained in the 'tool/' directory
+/// to the latest Flutter candidate branch.
+///
+/// When the '--local' flag is passed, your local flutter/flutter checkout will
+/// be updated as well.
+///
+/// This command will use the Flutter version from the 'flutter-candidate.txt'
+/// file in the repository root, unless the '--no-use-cache' flag is passed,
+/// in which case it will run the 'tool/latest_flutter_candidate.sh' script to
+/// fetch the latest version from upstream.
+///
+/// The version from 'flutter-candidate.txt' should be identical most of the
+/// time since the GitHub workflow that updates this file runs twice per day.
+///
+/// To run this script:
+/// `devtools_tool update-flutter-sdk [--local] [--no-use-cache]`
+class UpdateFlutterSdkCommand extends Command {
+  UpdateFlutterSdkCommand() {
+    argParser
+      ..addFlag(
+        _localFlag,
+        negatable: false,
+        help: 'Update your local checkout of the Flutter SDK',
+      )
+      ..addFlag(
+        _useCacheFlag,
+        negatable: true,
+        defaultsTo: true,
+        help:
+            'Use the cached Flutter version stored in "flutter-candidate.txt" '
+            'instead of the latest version at '
+            '"https://flutter.googlesource.com/mirrors/flutter/"',
+      );
+  }
+  @override
+  String get name => 'update-flutter-sdk';
+
+  @override
+  String get description =>
+      'Updates the "devtools_rev" hash in the Dart SDK DEPS file with the '
+      'provided commit hash, and creates a Gerrit CL for review';
+
+  @override
+  Future run() async {
+    final updateLocalFlutter = argResults![_localFlag];
+    final useCachedVersion = argResults![_useCacheFlag];
+    final log = Logger.standard();
+
+    // TODO(kenz): we can remove this if we can rewrite the
+    // 'latest_flutter_candidate.sh' script as a Dart script, or if we instead
+    // duplicate it as a Windows script (we may need this to be a non-Dart
+    // script for execution on the bots before we have a Dart SDK available).
+    if (Platform.isWindows && !useCachedVersion) {
+      log.stderr(
+        'On windows, you can only use the cached Flutter version from '
+        '"flutter-candidate.txt". Please remove the "--no-use-cache" flag and '
+        'try again.',
+      );
+      return 1;
+    }
+
+    final repo = DevToolsRepo.getInstance();
+    final processManager = ProcessManager();
+
+    late String flutterTag;
+    if (useCachedVersion) {
+      flutterTag =
+          'tags/${repo.readFile(Uri.parse('flutter-candidate.txt')).trim()}';
+    } else {
+      flutterTag = (await processManager.runProcess(
+        CliCommand('sh latest_flutter_candidate.sh'),
+        workingDirectory: repo.toolDirectoryPath,
+      ))
+          .stdout
+          .replaceFirst('refs/', '');
+    }
+
+    log.stdout(
+      'Updating to Flutter version '
+      '${useCachedVersion ? 'from cache' : 'from upstream'}: $flutterTag ',
+    );
+
+    if (updateLocalFlutter) {
+      final sdk = FlutterSdk.getSdk();
+      if (sdk == null) {
+        print('Unable to locate a Flutter sdk.');
+        return 1;
+      }
+
+      log.stdout('Updating local Flutter checkout...');
+
+      // Verify we have an upstream remote to pull from.
+      await findRemote(
+        processManager,
+        remoteId: 'flutter/flutter.git',
+        workingDirectory: sdk.sdkPath,
+      );
+
+      await processManager.runAll(
+        commands: [
+          CliCommand.git('stash'),
+          CliCommand.git('fetch upstream'),
+          CliCommand.git('checkout upstream/master'),
+          CliCommand.git('reset --hard upstream/master'),
+          CliCommand.git('checkout $flutterTag -f'),
+          CliCommand.flutter('--version'),
+        ],
+        workingDirectory: sdk.sdkPath,
+      );
+      log.stdout('Finished updating local Flutter checkout.');
+    }
+
+    final flutterSdkDirName = 'flutter-sdk';
+    final toolSdkPath = path.join(
+      repo.toolDirectoryPath,
+      flutterSdkDirName,
+    );
+    final toolFlutterSdk = Directory.fromUri(Uri.parse(toolSdkPath));
+    log.stdout('Updating "$toolSdkPath" to branch $flutterTag');
+
+    if (toolFlutterSdk.existsSync()) {
+      log.stdout('"$toolSdkPath" directory already exists');
+      await processManager.runAll(
+        commands: [
+          CliCommand.git('fetch'),
+          CliCommand.git('checkout $flutterTag -f'),
+          CliCommand('./bin/flutter --version'),
+        ],
+        workingDirectory: toolFlutterSdk.path,
+      );
+    } else {
+      log.stdout('"$toolSdkPath" directory does not exist - cloning it now');
+      await processManager.runProcess(
+        CliCommand.git(
+          'clone https://github.com/flutter/flutter $flutterSdkDirName',
+        ),
+        workingDirectory: repo.toolDirectoryPath,
+      );
+      await processManager.runAll(
+        commands: [
+          CliCommand.git('checkout $flutterTag -f'),
+          CliCommand('./bin/flutter --version'),
+        ],
+        workingDirectory: toolFlutterSdk.path,
+      );
+    }
+
+    log.stdout('Finished updating $toolSdkPath.');
+  }
+}
diff --git a/tool/lib/devtools_command_runner.dart b/tool/lib/devtools_command_runner.dart
index 371ee2a..29afd0e 100644
--- a/tool/lib/devtools_command_runner.dart
+++ b/tool/lib/devtools_command_runner.dart
@@ -7,6 +7,7 @@
 import 'package:devtools_tool/commands/fix_goldens.dart';
 import 'package:devtools_tool/commands/generate_code.dart';
 import 'package:devtools_tool/commands/sync.dart';
+import 'package:devtools_tool/commands/update_flutter_sdk.dart';
 import 'package:io/io.dart';
 
 import 'commands/analyze.dart';
@@ -22,16 +23,17 @@
   DevToolsCommandRunner()
       : super('devtools_tool', 'A repo management tool for DevTools.') {
     addCommand(AnalyzeCommand());
-    addCommand(RepoCheckCommand());
-    addCommand(ListCommand());
-    addCommand(PubGetCommand());
-    addCommand(RollbackCommand());
-    addCommand(UpdateDartSdkDepsCommand());
-    addCommand(ReleaseHelperCommand());
-    addCommand(UpdateDevToolsVersionCommand());
     addCommand(FixGoldensCommand());
     addCommand(GenerateCodeCommand());
+    addCommand(ListCommand());
+    addCommand(PubGetCommand());
+    addCommand(ReleaseHelperCommand());
+    addCommand(RepoCheckCommand());
+    addCommand(RollbackCommand());
     addCommand(SyncCommand());
+    addCommand(UpdateDartSdkDepsCommand());
+    addCommand(UpdateDevToolsVersionCommand());
+    addCommand(UpdateFlutterSdkCommand());
   }
 
   @override
diff --git a/tool/lib/model.dart b/tool/lib/model.dart
index 5fb46f5..9eed7fa 100644
--- a/tool/lib/model.dart
+++ b/tool/lib/model.dart
@@ -9,8 +9,12 @@
 class DevToolsRepo {
   DevToolsRepo._create(this.repoPath);
 
+  /// The path to the DevTools repository root.
   final String repoPath;
 
+  /// The path to the DevTools 'tool' directory.
+  String get toolDirectoryPath => path.join(repoPath, 'tool');
+
   @override
   String toString() => '[DevTools $repoPath]';
 
@@ -80,8 +84,9 @@
     }
   }
 
-  String readFile(String filePath) {
-    return File(path.join(repoPath, filePath)).readAsStringSync();
+  /// Reads the file at [uri], which should be a relative path from [repoPath].
+  String readFile(Uri uri) {
+    return File(path.join(repoPath, uri.path)).readAsStringSync();
   }
 }
 
diff --git a/tool/lib/utils.dart b/tool/lib/utils.dart
index 4928d92..6157dc3 100644
--- a/tool/lib/utils.dart
+++ b/tool/lib/utils.dart
@@ -22,9 +22,9 @@
       workingDirectory: dartSdkLocation,
       additionalErrorMessage: commandDebugMessage,
       commands: [
-        CliCommand('git fetch origin'),
-        CliCommand('git rebase-update'),
-        CliCommand('git checkout origin/main'),
+        CliCommand.git('fetch origin'),
+        CliCommand.git('rebase-update'),
+        CliCommand.git('checkout origin/main'),
       ],
     );
   }
@@ -84,6 +84,17 @@
     );
   }
 
+  factory CliCommand.git(
+    String args, {
+    bool throwOnException = true,
+  }) {
+    return CliCommand._(
+      exe: 'git',
+      args: args.split(' '),
+      throwOnException: throwOnException,
+    );
+  }
+
   factory CliCommand.tool(
     String args, {
     bool throwOnException = true,
@@ -98,6 +109,11 @@
   late final String exe;
   late final List<String> args;
   final bool throwOnException;
+
+  @override
+  String toString() {
+    return [exe, ...args].join(' ');
+  }
 }
 
 typedef DevToolsProcessResult = ({int exitCode, String stdout, String stderr});
@@ -152,3 +168,46 @@
 String pathFromRepoRoot(String pathFromRoot) {
   return path.join(DevToolsRepo.getInstance().repoPath, pathFromRoot);
 }
+
+/// Returns the name of the git remote with id [remoteId] in
+/// [workingDirectory].
+/// 
+/// When [workingDirectory] is null, this method will look for the remote in
+/// the current directory.
+/// 
+/// [remoteId] should have the form <organization>/<repository>.git. For
+/// example: 'flutter/flutter.git' or 'flutter/devtools.git'.
+Future<String> findRemote(
+  ProcessManager processManager, {
+  required String remoteId,
+  String? workingDirectory,
+}) async {
+  print('Searching for a remote that points to $remoteId.');
+  final remotesResult = await processManager.runProcess(
+    CliCommand.git('remote -v'),
+    workingDirectory: workingDirectory,
+  );
+  final String remotes = remotesResult.stdout;
+  final remoteRegexp = RegExp(
+    r'^(?<remote>\S+)\s+(?<path>\S+)\s+\((?<action>\S+)\)',
+    multiLine: true,
+  );
+  final remoteRegexpResults = remoteRegexp.allMatches(remotes);
+  final RegExpMatch upstreamRemoteResult;
+
+  try {
+    upstreamRemoteResult = remoteRegexpResults.firstWhere(
+      // ignore: prefer_interpolation_to_compose_strings
+      (element) => RegExp(r'' + remoteId + '\$')
+          .hasMatch(element.namedGroup('path')!),
+    );
+  } on StateError {
+    throw StateError(
+      "Couldn't find a remote that points to flutter/devtools.git. "
+      "Instead got: \n$remotes",
+    );
+  }
+  final remoteUpstream = upstreamRemoteResult.namedGroup('remote')!;
+  print('Found upstream remote.');
+  return remoteUpstream;
+}
diff --git a/tool/update_flutter_sdk.sh b/tool/update_flutter_sdk.sh
deleted file mode 100755
index b4562f8..0000000
--- a/tool/update_flutter_sdk.sh
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/bin/bash
-
-# Copyright 2021 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style license that can be
-# found in the LICENSE file.
-
-# Any subsequent commands failure will cause this script to exit immediately
-set -e
-
-UPDATE_LOCALLY=$1
-
-# Contains a path to this script, relative to the directory it was called from.
-RELATIVE_PATH_TO_SCRIPT="${BASH_SOURCE[0]}"
-
-# The directory that this script is located in.
-TOOL_DIR=`dirname "${RELATIVE_PATH_TO_SCRIPT}"`
-
-pushd "$TOOL_DIR"
-dart pub get
-REQUIRED_FLUTTER_TAG="$(./latest_flutter_candidate.sh | sed 's/^.*refs\///')"
-
-echo "REQUIRED_FLUTTER_TAG: $REQUIRED_FLUTTER_TAG"
-
-if [[ $UPDATE_LOCALLY = "--local" ]]; then
-  echo "STATUS: Updating local Flutter checkout to branch '$REQUIRED_FLUTTER_TAG'."
-
-  FLUTTER_EXE=`which flutter`
-  FLUTTER_BIN=`dirname "${FLUTTER_EXE}"`
-  FLUTTER_DIR="$FLUTTER_BIN/.."
-
-  pushd $FLUTTER_DIR
-
-  UPSTREAM_REMOTE_COUNT=$(git remote -v| grep -cE '^upstream[[:space:]]+git@github.com:flutter/flutter.git' || true)
-  if [ "$UPSTREAM_REMOTE_COUNT" -lt "2" ] ; then
-    echo "Error: please make sure the flutter repository 'upstream' remote is set to 'git@github.com:flutter/flutter.git'";
-    exit 1;
-  fi 
-  # Stash any local flutter SDK changes if they exist.
-  git stash
-  git fetch upstream
-  git checkout upstream/master
-  git reset --hard upstream/master
-  git checkout $REQUIRED_FLUTTER_TAG -f
-  flutter --version
-  popd
-
-  echo "STATUS: Finished updating local Flutter checkout."
-fi
-
-FLUTTER_DIR="flutter-sdk"
-PATH="$FLUTTER_DIR/bin":$PATH
-
-echo "STATUS: Updating 'tool/flutter-sdk' to branch '$REQUIRED_FLUTTER_TAG'."
-
-if [ -d "$FLUTTER_DIR" ]; then
-  echo "STATUS: 'tool/$FLUTTER_DIR' directory already exists"
-
-  # switch to the specified version
-  pushd $FLUTTER_DIR
-  git fetch
-  git checkout $REQUIRED_FLUTTER_TAG -f
-  ./bin/flutter --version
-  popd
-else
-  echo "STATUS: 'tool/$FLUTTER_DIR' directory does not exist - cloning it now"
-
-  # clone the flutter repo and switch to the specified version
-  git clone https://github.com/flutter/flutter "$FLUTTER_DIR"
-  pushd "$FLUTTER_DIR"
-  git checkout $REQUIRED_FLUTTER_TAG
-  ./bin/flutter --version
-  popd
-fi
-
-popd
-echo "STATUS: Finished updating 'tool/flutter-sdk'."