Merge pull request #652 from dart-lang/flutter_sdk_configuration

add the ability to provision a flutter sdk via a configuration file
diff --git a/.gitignore b/.gitignore
index c15e696..2816616 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@
 
 # Editor configuration
 .vscode/
+
+# A Flutter SDK checkout
+flutter-sdk/
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 04f3167..a747c44 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -6,6 +6,7 @@
     implicit-casts: false
   exclude:
     - dart-sdk/**
+    - flutter-sdk/**
     - doc/generated/**
     - flutter/**
     # TODO: This seems to be hiding ~2 dozen legitimate analysis issues. https://github.com/dart-lang/dart-pad/issues/1712
diff --git a/flutter-sdk-version.yaml b/flutter-sdk-version.yaml
new file mode 100644
index 0000000..e1fb793
--- /dev/null
+++ b/flutter-sdk-version.yaml
@@ -0,0 +1,15 @@
+# The channel or version of the Flutter SDK to use.
+
+# Note: either the 'channel' field or the 'version' field should be provided
+# below, but not both. If neither a channel nor version is provided then no
+# particular git configuration will be done to the Flutter SDK in `flutter-sdk/`
+# (apart from any initial git clone).
+
+# The dart-services server will automatically create or configure the
+# flutter-sdk/ directory based on this configuration file at startup.
+
+# In order to update the SDK manually, run 'dart tool/update_sdk.dart'.
+
+flutter_sdk:
+  channel: beta
+  # version: 1.25.0-8.1.pre
diff --git a/lib/src/sdk_manager.dart b/lib/src/sdk_manager.dart
index fd6cb68..5ea8fa6 100644
--- a/lib/src/sdk_manager.dart
+++ b/lib/src/sdk_manager.dart
@@ -3,9 +3,12 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert' show utf8;
 import 'dart:io';
 
+import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
+import 'package:yaml/yaml.dart';
 
 /// Generally, this should be a singleton instance (it's a heavy-weight object).
 class SdkManager {
@@ -50,16 +53,12 @@
 
 /// Represents a Dart SDK present on the server.
 class PlatformSdk extends Sdk {
-  String _versionFull = '';
+  @override
+  Future<void> init() => Future.value();
 
   @override
-  Future<void> init() async {
-    _versionFull =
-        (await File(path.join(sdkPath, 'version')).readAsString()).trim();
-  }
-
-  @override
-  String get versionFull => _versionFull;
+  String get versionFull =>
+      File(path.join(sdkPath, 'version')).readAsStringSync().trim();
 
   @override
   String get sdkPath => path.dirname(path.dirname(Platform.resolvedExecutable));
@@ -94,3 +93,215 @@
 
   String get flutterVersion => _flutterVersion;
 }
+
+// TODO: Have an option to skip git calls (for testing)?
+// TODO(devoncarew): Collapse this with the other SDK managers.
+class DownloadingSdkManager {
+  DownloadingSdkManager();
+
+  /// Read and return the Flutter sdk configuration file info
+  /// (`flutter-sdk-version.yaml`).
+  static Map<String, Object> getSdkConfigInfo() {
+    final File file =
+        File(path.join(Directory.current.path, 'flutter-sdk-version.yaml'));
+    return (loadYaml(file.readAsStringSync()) as Map).cast<String, Object>();
+  }
+
+  /// Create a Flutter SDK in `flutter-sdk/` that configured using the
+  /// `flutter-sdk-version.yaml` file.
+  ///
+  /// Note that this is an expensive operation.
+  Future<DownloadedFlutterSdk> createFromConfigFile() async {
+    final Map<String, Object> sdkConfig = getSdkConfigInfo();
+
+    // flutter_sdk:
+    //   channel: beta
+    //   #version: 1.25.0-8.1.pre
+    if (!sdkConfig.containsKey('flutter_sdk')) {
+      throw "No key 'flutter_sdk' found in sdk config file";
+    }
+
+    final Map<String, Object> config =
+        (sdkConfig['flutter_sdk'] as Map).cast<String, Object>();
+
+    if (config.containsKey('channel') && config.containsKey('version')) {
+      throw "config file contains both 'channel' and 'version' config settings";
+    }
+
+    if (config.containsKey('channel')) {
+      return createUsingFlutterChannel(channel: config['channel'] as String);
+    } else if (config.containsKey('version')) {
+      return createUsingFlutterVersion(version: config['version'] as String);
+    } else {
+      // Clone the repo if necessary but don't do any other setup.
+      return _cloneSdkIfNecessary();
+    }
+  }
+
+  /// Create a Flutter SDK in `flutter-sdk/` that tracks a specific Flutter
+  /// channel.
+  ///
+  /// Note that this is an expensive operation.
+  Future<DownloadedFlutterSdk> createUsingFlutterChannel({
+    @required String channel,
+  }) async {
+    final DownloadedFlutterSdk sdk = await _cloneSdkIfNecessary();
+
+    // git checkout master
+    await sdk.checkout('master');
+
+    // Check if 'beta' exists.
+    if (await sdk.checkChannelAvailableLocally(channel)) {
+      await sdk.checkout(channel);
+    } else {
+      await sdk.trackChannel(channel);
+    }
+
+    // git pull
+    await sdk.pull();
+
+    return sdk;
+  }
+
+  /// Create a Flutter SDK in `flutter-sdk/` that tracks a specific Flutter
+  /// version.
+  ///
+  /// Note that this is an expensive operation.
+  Future<DownloadedFlutterSdk> createUsingFlutterVersion({
+    @required String version,
+  }) async {
+    final DownloadedFlutterSdk sdk = await _cloneSdkIfNecessary();
+
+    // git checkout master
+    await sdk.checkout('master');
+    // git fetch --tags
+    await sdk.fetchTags();
+    // git checkout 1.25.0-8.1.pre
+    await sdk.checkout(version);
+
+    return sdk;
+  }
+
+  Future<DownloadedFlutterSdk> _cloneSdkIfNecessary() async {
+    final DownloadedFlutterSdk sdk = DownloadedFlutterSdk();
+
+    if (!Directory(sdk.flutterSdkPath).existsSync()) {
+      // This takes perhaps ~20 seconds.
+      await sdk.clone(
+        [
+          '--depth',
+          '1',
+          '--no-single-branch',
+          'https://github.com/flutter/flutter',
+          sdk.flutterSdkPath,
+        ],
+        cwd: Directory.current.path,
+      );
+    }
+
+    return sdk;
+  }
+}
+
+class DownloadedFlutterSdk extends Sdk {
+  @override
+  Future<void> init() async {
+    // flutter --version takes ~28s
+    await _execLog('bin/flutter', ['--version'], flutterSdkPath);
+  }
+
+  @override
+  String get sdkPath => path.join(flutterSdkPath, 'bin/cache/dart-sdk');
+
+  @override
+  String get versionFull =>
+      File(path.join(sdkPath, 'version')).readAsStringSync().trim();
+
+  String get flutterSdkPath => path.join(Directory.current.path, 'flutter-sdk');
+
+  String get flutterVersion =>
+      File(path.join(flutterSdkPath, 'version')).readAsStringSync().trim();
+
+  /// Perform a git clone, logging the command and any output, and throwing an
+  /// exception if there are any issues with the clone.
+  Future<void> clone(List<String> args, {@required String cwd}) async {
+    final result = await _execLog('git', ['clone', ...args], cwd);
+    if (result != 0) {
+      throw 'result from git clone: $result';
+    }
+  }
+
+  Future<void> checkout(String branch) async {
+    final result = await _execLog('git', ['checkout', branch], flutterSdkPath);
+    if (result != 0) {
+      throw 'result from git checkout: $result';
+    }
+  }
+
+  Future<void> fetchTags() async {
+    final result = await _execLog('git', ['fetch', '--tags'], flutterSdkPath);
+    if (result != 0) {
+      throw 'result from git fetch: $result';
+    }
+  }
+
+  Future<void> pull() async {
+    final result = await _execLog('git', ['pull'], flutterSdkPath);
+    if (result != 0) {
+      throw 'result from git pull: $result';
+    }
+  }
+
+  Future<void> trackChannel(String channel) async {
+    // git checkout --track -b beta origin/beta
+    final result = await _execLog(
+      'git',
+      [
+        'checkout',
+        '--track',
+        '-b',
+        channel,
+        'origin/$channel',
+      ],
+      flutterSdkPath,
+    );
+    if (result != 0) {
+      throw 'result from git checkout $channel: $result';
+    }
+  }
+
+  Future<bool> checkChannelAvailableLocally(String channel) async {
+    // git show-ref --verify --quiet refs/heads/beta
+    final result = await _execLog(
+      'git',
+      [
+        'show-ref',
+        '--verify',
+        '--quiet',
+        'refs/heads/$channel',
+      ],
+      flutterSdkPath,
+    );
+
+    return result == 0;
+  }
+
+  Future<int> _execLog(
+      String executable, List<String> arguments, String cwd) async {
+    print('$executable ${arguments.join(' ')}');
+
+    final process = await Process.start(
+      executable,
+      arguments,
+      workingDirectory: cwd,
+    );
+    process.stdout
+        .transform<String>(utf8.decoder)
+        .listen((string) => stdout.write(string));
+    process.stderr
+        .transform<String>(utf8.decoder)
+        .listen((string) => stderr.write(string));
+
+    return await process.exitCode;
+  }
+}
diff --git a/tool/update_sdk.dart b/tool/update_sdk.dart
new file mode 100644
index 0000000..583f98d
--- /dev/null
+++ b/tool/update_sdk.dart
@@ -0,0 +1,18 @@
+// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:dart_services/src/sdk_manager.dart';
+
+// This tool is used to manually update the `flutter-sdk/` Flutter SDK to match
+// the current configuration information in the `flutter-sdk-version.yaml` file.
+
+void main(List<String> args) async {
+  final info = DownloadingSdkManager.getSdkConfigInfo();
+  print('configuration: $info\n');
+
+  final DownloadingSdkManager sdkManager = DownloadingSdkManager();
+  final DownloadedFlutterSdk sdk = await sdkManager.createFromConfigFile();
+
+  print('\nSDK setup complete (${sdk.flutterSdkPath}).');
+}