Add a `devtools_extensions validate` command (#7257)

diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 95e563c..62898f0 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -74,6 +74,7 @@
 subdirectories. - [#7174](https://github.com/flutter/devtools/pull/7174)
 * Added an example of creating an extension for a pure Dart package. - [#7196](https://github.com/flutter/devtools/pull/7196)
 * Updated the `example/README.md` with more complete documentation. - [#7237](https://github.com/flutter/devtools/pull/7237)
+* Added a `devtools_extensions validate` command to validate extension requirements during development. - [#7257](https://github.com/flutter/devtools/pull/7257)
 
 ## Full commit history
 
diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md
index 95bd6a2..10161c0 100644
--- a/packages/devtools_app_shared/CHANGELOG.md
+++ b/packages/devtools_app_shared/CHANGELOG.md
@@ -1,4 +1,4 @@
-## 0.0.10-wip
+## 0.0.10
 * Add `DTDManager` class and export from `service.dart`.
 * Add `showDevToolsDialog` helper method.
 * Add `FlexSplitColumn` and `BlankHeader` common widgets.
diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml
index 1b133ca..75bf29a 100644
--- a/packages/devtools_app_shared/pubspec.yaml
+++ b/packages/devtools_app_shared/pubspec.yaml
@@ -1,6 +1,6 @@
 name: devtools_app_shared
 description: Package of Dart & Flutter structures shared between devtools_app and devtools extensions.
-version: 0.0.10-wip
+version: 0.0.10
 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_app_shared
 
 environment:
diff --git a/packages/devtools_extensions/CHANGELOG.md b/packages/devtools_extensions/CHANGELOG.md
index 7cab68a..e08adb9 100644
--- a/packages/devtools_extensions/CHANGELOG.md
+++ b/packages/devtools_extensions/CHANGELOG.md
@@ -1,4 +1,4 @@
-## 0.0.14-wip
+## 0.0.14
 * Add a global `dtdManager` for interacting with the Dart Tooling Daemon.
 * Add support for connecting to the Dart Tooling Daemon from the 
 simulated DevTools environment.
@@ -8,6 +8,7 @@
 * Refactor `example` directory to support more package examples.
 * Add an example of providing an extension from a pure Dart package.
 * Update the `example/README.md`.
+* Add a `devtools_extensions validate` for validating extension requirements.
 
 ## 0.0.13
 * Bump `package:web` to `^0.4.1`.
diff --git a/packages/devtools_extensions/README.md b/packages/devtools_extensions/README.md
index 578f2c4..4c11b04 100644
--- a/packages/devtools_extensions/README.md
+++ b/packages/devtools_extensions/README.md
@@ -198,6 +198,16 @@
 dart run devtools_extensions build_and_copy --source=. --dest=../foo/extension/devtools
 ```
 
+To ensure that your extension is setup properly for loading in DevTools, run the
+`validate` command from `package:devtools_extensions`. The `--package` argument
+should point to the root of the Dart package that this extension will be published
+with.
+```sh
+cd your_extension_web_app;
+flutter pub get;
+dart run devtools_extensions validate --package=../foo
+```
+
 2. Prepare and run a test application that depends on your pub package. You'll need to change the
 `pubspec.yaml` dependency to be a [path](https://dart.dev/tools/pub/dependencies#path-packages)
 dependency that points to your local pub package source code. Once you have done this,
diff --git a/packages/devtools_extensions/analysis_options.yaml b/packages/devtools_extensions/analysis_options.yaml
index 35d2ba4..83f90ee 100644
--- a/packages/devtools_extensions/analysis_options.yaml
+++ b/packages/devtools_extensions/analysis_options.yaml
@@ -2,5 +2,4 @@
 
 analyzer:
   exclude:
-    - bin/**
     - example/**
diff --git a/packages/devtools_extensions/bin/_build_and_copy.dart b/packages/devtools_extensions/bin/_build_and_copy.dart
index 40f70c0..8f90941 100644
--- a/packages/devtools_extensions/bin/_build_and_copy.dart
+++ b/packages/devtools_extensions/bin/_build_and_copy.dart
@@ -102,7 +102,7 @@
     if (destinationDirectory.existsSync()) {
       destinationDirectory.deleteSync(recursive: true);
     }
-    Directory(destinationBuildPath)..createSync(recursive: true);
+    Directory(destinationBuildPath).createSync(recursive: true);
 
     await copyPath(
       sourceBuildPath,
@@ -116,7 +116,7 @@
     );
   }
 
-  void _log(String message) => print('[$name] $message');
+  void _log(String message) => stdout.writeln('[$name] $message');
 
   Future<void> _runProcess(
     ProcessManager processManager,
diff --git a/packages/devtools_extensions/bin/_validate.dart b/packages/devtools_extensions/bin/_validate.dart
new file mode 100644
index 0000000..e0c5f80
--- /dev/null
+++ b/packages/devtools_extensions/bin/_validate.dart
@@ -0,0 +1,138 @@
+// Copyright 2024 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:devtools_shared/devtools_extensions.dart';
+import 'package:devtools_shared/devtools_extensions_io.dart';
+import 'package:path/path.dart' as path;
+import 'package:yaml/yaml.dart';
+
+/// Command that validates that a DevTools extension meets the requirements for
+/// loading successfully in DevTools.
+///
+/// Example usage:
+///
+/// dart run devtools_extensions validate --package=../your_pub_package
+class ValidateExtensionCommand extends Command {
+  ValidateExtensionCommand() {
+    argParser.addOption(
+      _packageKey,
+      help: 'The location of the package that this extension is published with',
+      abbr: 'p',
+      valueHelp: 'path/to/foo/packages/foo',
+      mandatory: true,
+    );
+  }
+
+  static const _packageKey = 'package';
+
+  @override
+  String get name => 'validate';
+
+  @override
+  String get description =>
+      'Command that validates that a DevTools extension meets the '
+      'requirements for loading successfully in DevTools.';
+
+  static const docUrl = 'https://docs.flutter.dev/tools/devtools/extensions';
+
+  @override
+  Future<void> run() async {
+    final packagePath = argResults?[_packageKey]! as String;
+    try {
+      // TODO(kenz): try to use the the existing pub validator for this check. See
+      // https://github.com/dart-lang/pub/blob/master/lib/src/validator/devtools_extension.dart.
+      _validateDirectoryContents(packagePath);
+
+      // Try to parse the config.yaml file. This will throw an exception if there
+      // are parsing errors.
+      DevToolsExtensionConfig.parse(
+        {
+          ..._configAsMap(packagePath),
+          // These are generated on the DevTools server, so pass in stubbed
+          // values for the sake of validation.
+          DevToolsExtensionConfig.isPubliclyHostedKey: 'false',
+          DevToolsExtensionConfig.pathKey: '',
+        },
+      );
+
+      // If there are no exceptions at this point, the extension has successfully
+      // been validated.
+      stdout.writeln('Extension validation successful');
+    } on StateError catch (e) {
+      _logError(e.message);
+    } on FileSystemException catch (e) {
+      _logError(e.message);
+    } catch (e) {
+      _logError(e.toString());
+    }
+  }
+}
+
+void _validateDirectoryContents(String packagePath) {
+  final packageDirectory = Directory(packagePath);
+  if (!packageDirectory.existsSync()) {
+    throw FileSystemException('${packageDirectory.path} directory not found');
+  }
+
+  final devtoolsExtensionDir = Directory(
+    path.join(packageDirectory.path, 'extension', 'devtools'),
+  );
+  if (!devtoolsExtensionDir.existsSync()) {
+    throw const FileSystemException(
+      '''
+An extension/devtools directory is required, but none was found.
+See ${ValidateExtensionCommand.docUrl}.
+''',
+    );
+  }
+
+  final buildDir = Directory(path.join(devtoolsExtensionDir.path, 'build'));
+  if (!buildDir.existsSync()) {
+    throw const FileSystemException(
+      '''
+An extension/devtools/build directory is required, but none was found.
+See ${ValidateExtensionCommand.docUrl}.
+''',
+    );
+  }
+  if (buildDir.listSync().isEmpty) {
+    throw const FileSystemException(
+      '''
+A non-empty extension/devtools/build directory is required, but the directory is empty.
+See ${ValidateExtensionCommand.docUrl}.
+''',
+    );
+  }
+
+  final configFile = _lookupConfigFile(packagePath);
+  if (!configFile.existsSync()) {
+    throw const FileSystemException(
+      '''
+An extension/devtools/config.yaml file is required, but none was found.
+See ${ValidateExtensionCommand.docUrl}.
+''',
+    );
+  }
+}
+
+Map<String, Object?> _configAsMap(String packagePath) {
+  final configFile = _lookupConfigFile(packagePath);
+  // At this point, we know the config.yaml file exists.
+  assert(configFile.existsSync());
+  final yamlMap = loadYaml(configFile.readAsStringSync()) as YamlMap;
+  return yamlMap.toDartMap();
+}
+
+File _lookupConfigFile(String packagePath) {
+  return File(
+    path.join(packagePath, 'extension', 'devtools', 'config.yaml'),
+  );
+}
+
+void _logError(String error) {
+  stderr.writeln('Validation error: $error');
+}
diff --git a/packages/devtools_extensions/bin/devtools_extensions.dart b/packages/devtools_extensions/bin/devtools_extensions.dart
index 853b7e7..4acf18a 100644
--- a/packages/devtools_extensions/bin/devtools_extensions.dart
+++ b/packages/devtools_extensions/bin/devtools_extensions.dart
@@ -6,10 +6,12 @@
 import 'package:io/io.dart';
 
 import '_build_and_copy.dart';
+import '_validate.dart';
 
 void main(List<String> arguments) async {
   final command = BuildExtensionCommand();
   final runner = CommandRunner('devtools_extensions', command.description)
-    ..addCommand(BuildExtensionCommand());
+    ..addCommand(BuildExtensionCommand())
+    ..addCommand(ValidateExtensionCommand());
   await runner.run(arguments).whenComplete(sharedStdIn.terminate);
 }
diff --git a/packages/devtools_extensions/pubspec.yaml b/packages/devtools_extensions/pubspec.yaml
index 3e5f538..09cd7d0 100644
--- a/packages/devtools_extensions/pubspec.yaml
+++ b/packages/devtools_extensions/pubspec.yaml
@@ -1,6 +1,6 @@
 name: devtools_extensions
 description: A package for building and supporting extensions for Dart DevTools.
-version: 0.0.14-wip
+version: 0.0.14
 
 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions
 
@@ -21,7 +21,8 @@
   path: ^1.8.0
   logging: ^1.1.1
   vm_service: ^14.0.0
-  web: ^0.4.1
+  web: ^0.4.1  
+  yaml: ^3.1.2
 
 dev_dependencies:
   flutter_driver:
diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md
index b5f0da5..ec1efbf 100644
--- a/packages/devtools_shared/CHANGELOG.md
+++ b/packages/devtools_shared/CHANGELOG.md
@@ -1,3 +1,6 @@
+# 7.0.1-wip
+* Refactor yaml extension methods.
+
 # 7.0.0
 * **Breaking change:** remove the `ServerApi.setCompleted` method that was a
 duplicate of `ServerApi.getCompleted`.
diff --git a/packages/devtools_shared/lib/devtools_extensions_io.dart b/packages/devtools_shared/lib/devtools_extensions_io.dart
index 37ef521..985717b 100644
--- a/packages/devtools_shared/lib/devtools_extensions_io.dart
+++ b/packages/devtools_shared/lib/devtools_extensions_io.dart
@@ -4,3 +4,4 @@
 
 export 'src/extensions/extension_enablement.dart';
 export 'src/extensions/extension_manager.dart';
+export 'src/extensions/yaml_utils.dart';
diff --git a/packages/devtools_shared/lib/src/extensions/extension_enablement.dart b/packages/devtools_shared/lib/src/extensions/extension_enablement.dart
index 721f781..95d1b56 100644
--- a/packages/devtools_shared/lib/src/extensions/extension_enablement.dart
+++ b/packages/devtools_shared/lib/src/extensions/extension_enablement.dart
@@ -10,6 +10,7 @@
 import 'package:yaml_edit/yaml_edit.dart';
 
 import 'extension_model.dart';
+import 'yaml_utils.dart';
 
 /// Manages the `devtools_options.yaml` file and allows read / write access.
 class DevToolsOptions {
@@ -144,34 +145,3 @@
     }
   }
 }
-
-extension YamlExtension on YamlMap {
-  Map<String, Object?> toDartMap() {
-    final map = <String, Object?>{};
-    for (final entry in nodes.entries) {
-      map[entry.key.toString()] = entry.value.convertToDartType();
-    }
-    return map;
-  }
-}
-
-extension YamlListExtension on YamlList {
-  List<Object?> toDartList() {
-    final list = <Object>[];
-    for (final e in nodes) {
-      final element = e.convertToDartType();
-      if (element != null) list.add(element);
-    }
-    return list;
-  }
-}
-
-extension YamlNodeExtension on YamlNode {
-  Object? convertToDartType() {
-    return switch (this) {
-      YamlMap() => (this as YamlMap).toDartMap(),
-      YamlList() => (this as YamlList).toDartList(),
-      _ => value,
-    };
-  }
-}
diff --git a/packages/devtools_shared/lib/src/extensions/yaml_utils.dart b/packages/devtools_shared/lib/src/extensions/yaml_utils.dart
new file mode 100644
index 0000000..b000569
--- /dev/null
+++ b/packages/devtools_shared/lib/src/extensions/yaml_utils.dart
@@ -0,0 +1,36 @@
+// Copyright (c) 2024, 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:yaml/yaml.dart';
+
+extension YamlExtension on YamlMap {
+  Map<String, Object?> toDartMap() {
+    final map = <String, Object?>{};
+    for (final entry in nodes.entries) {
+      map[entry.key.toString()] = entry.value.convertToDartType();
+    }
+    return map;
+  }
+}
+
+extension YamlListExtension on YamlList {
+  List<Object?> toDartList() {
+    final list = <Object>[];
+    for (final e in nodes) {
+      final element = e.convertToDartType();
+      if (element != null) list.add(element);
+    }
+    return list;
+  }
+}
+
+extension YamlNodeExtension on YamlNode {
+  Object? convertToDartType() {
+    return switch (this) {
+      YamlMap() => (this as YamlMap).toDartMap(),
+      YamlList() => (this as YamlList).toDartList(),
+      _ => value,
+    };
+  }
+}
diff --git a/packages/devtools_shared/pubspec.yaml b/packages/devtools_shared/pubspec.yaml
index b15883f..1a7de1b 100644
--- a/packages/devtools_shared/pubspec.yaml
+++ b/packages/devtools_shared/pubspec.yaml
@@ -1,7 +1,7 @@
 name: devtools_shared
 description: Package of shared Dart structures between devtools_app, dds, and other tools.
 
-version: 7.0.0
+version: 7.0.1-wip
 
 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_shared