[dartdev] dart install

This CL adds three new commands to `dart`:

```
Global
  install     Install a Dart CLI tool for global use.
  installed   List globally installed Dart CLI tools.
  uninstall   Remove a globally installed Dart CLI tool.
```

These commands are intended to replace `dart pub global` subcommands
while adding support for `hook/build.dart` and building packages in
AOT instead of running them with JIT.

Internal design doc: http://go/dart-install-cli.

Implementation details:
* The source of truth is the state of the file system. These commands
  write and read directories, files, and symlinks.
* App bundles and symlinks are placed in `DART_DATA_HOME` as per
  http://go/dart-data-home.
* On Unix systems we use symlinks and on Windows batchfiles to place
  executables in the bin directory that point to an application
  bundle. (These OS differences have been encapsulated in a single
  class.)
* On Windows, when an application is running, trying to re-install it
  will fail.

Test coverage:
* Installing from hosted, git, and local paths.
* Installing a package with hooks.
* Installing a package with hooks and user-defines.
* Surfacing build hook failures during install.
* Installing packages with conflicting executables names. This tests
  `--overwrite` flag behavior.
* Installing a new or the same version, this should simply succeed.
* A warning is shown if the bin directory is not on the `PATH`.
* Running an installed app reports the correct exit code on exit.
* Listing all installed versions, including the versions not on
  the`PATH`.
* Uninstalling, which uninstalls all versions.
* Re-installing while it is running.
* Uninstalling while it is running.

Out of scope for initial version:
* Saving the SDK version (to display in `dart installed`).
* Short-circuiting if re-installing an exactly installed version.

Bug: https://github.com/dart-lang/sdk/issues/60889
Change-Id: I8f3a60d26e013957ce6fd7f52e564bcaaff30509
Cq-Include-Trybots: luci.dart.try:pkg-linux-debug-try,pkg-linux-release-arm64-try,pkg-linux-release-try,pkg-mac-release-arm64-try,pkg-mac-release-try,pkg-win-release-arm64-try,pkg-win-release-try
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/441581
Reviewed-by: Sigurd Meldgaard <sigurdm@google.com>
Commit-Queue: Daco Harkes <dacoharkes@google.com>
Reviewed-by: Michael Goderbauer <goderbauer@google.com>
This commit is contained in:
Daco Harkes
2025-08-13 01:33:59 -07:00
committed by Commit Queue
parent c92417ba82
commit 94894b1796
14 changed files with 2325 additions and 89 deletions
+8
View File
@@ -27,10 +27,13 @@ import 'src/commands/devtools.dart';
import 'src/commands/doc.dart';
import 'src/commands/fix.dart';
import 'src/commands/info.dart';
import 'src/commands/install.dart';
import 'src/commands/installed.dart';
import 'src/commands/language_server.dart';
import 'src/commands/run.dart';
import 'src/commands/test.dart';
import 'src/commands/tooling_daemon.dart';
import 'src/commands/uninstall.dart';
import 'src/core.dart';
import 'src/experiments.dart';
import 'src/unified_analytics.dart';
@@ -135,6 +138,11 @@ class DartdevRunner extends CommandRunner<int> {
nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled,
));
addCommand(ToolingDaemonCommand(verbose: verbose));
if (nativeAssetsExperimentEnabled) {
addCommand(InstallCommand(verbose: verbose));
addCommand(InstalledCommand(verbose: verbose));
addCommand(UninstallCommand(verbose: verbose));
}
}
@visibleForTesting
+165 -88
View File
@@ -14,6 +14,7 @@ import 'package:dartdev/src/native_assets_macos.dart';
import 'package:dartdev/src/sdk.dart';
import 'package:front_end/src/api_prototype/compiler_options.dart'
show Verbosity;
import 'package:hooks_runner/hooks_runner.dart';
import 'package:path/path.dart' as path;
import '../core.dart';
@@ -124,15 +125,21 @@ then that is used instead.''',
// AOT compilation isn't supported on ia32. Currently, generating an
// executable only supports AOT runtimes, so these commands are disabled.
if (Platform.version.contains('ia32')) {
stderr.write("'dart build' is not supported on x86 architectures");
stderr.write("'dart build' is not supported on x86 architectures.");
return 64;
}
final args = argResults!;
var target = args.option('target');
if (target == null) {
stderr.write(
'There are multiple possible targets in the `bin/` directory, '
"and the 'target' argument wasn't specified.",
);
return 255;
}
final sourceUri =
File.fromUri(Uri.file(args.option('target')!).normalizePath())
.absolute
.uri;
File.fromUri(Uri.file(target).normalizePath()).absolute.uri;
if (!checkFile(sourceUri.toFilePath())) {
return genericErrorExitCode;
}
@@ -146,38 +153,84 @@ then that is used instead.''',
stderr.writeln('Requested output directory: ${outputUri.toFilePath()}');
return 128;
}
final outputDir = Directory.fromUri(outputUri);
if (await outputDir.exists()) {
stdout.writeln('Deleting output directory: ${outputUri.toFilePath()}.');
await outputDir.delete(recursive: true);
}
final bundleDirectory = Directory.fromUri(outputUri.resolve('bundle/'));
final binDirectory = Directory.fromUri(bundleDirectory.uri.resolve('bin/'));
await binDirectory.create(recursive: true);
final outputExeUri = binDirectory.uri.resolve(
targetOS.executableFileName(
path.basenameWithoutExtension(sourceUri.path),
),
);
final verbosity = args.option('verbosity')!;
final enabledExperiments = args.enabledExperiments;
stdout.writeln('''The `dart build cli` command is in preview at the moment.
See documentation on https://dart.dev/interop/c-interop#native-assets.
''');
stdout.writeln('Building native assets.');
final packageConfigUri = await DartNativeAssetsBuilder.ensurePackageConfig(
sourceUri,
);
final pubspecUri =
await DartNativeAssetsBuilder.findWorkspacePubspec(packageConfigUri);
final executableName = path.basenameWithoutExtension(sourceUri.path);
return await doBuild(
executables: [(name: executableName, sourceEntryPoint: sourceUri)],
enabledExperiments: enabledExperiments,
outputUri: outputUri,
packageConfigUri: packageConfigUri!,
pubspecUri: pubspecUri,
recordUseEnabled: recordUseEnabled,
verbose: verbose,
verbosity: verbosity,
);
}
static Future<int> doBuild({
required DartBuildExecutables executables,
required Uri outputUri,
required Uri packageConfigUri,
required Uri? pubspecUri,
required bool recordUseEnabled,
required List<String> enabledExperiments,
required bool verbose,
required String verbosity,
}) async {
if (executables.length >= 2 && recordUseEnabled) {
// Multiple entry points can lead to multiple different tree-shakings.
// We either need to generate a new entry point that combines all entry
// points and combine that into a single executable and have wrappers
// around that executable. Or, we need to merge the recorded uses for the
// various entrypoints. The former will lead to smaller bundle-size
// overall.
stderr.writeln(
'Multiple executables together with record use is not yet supported.',
);
return 255;
}
final outputDir = Directory.fromUri(outputUri);
if (await outputDir.exists()) {
stdout.writeln('Deleting output directory: ${outputUri.toFilePath()}.');
try {
await outputDir.delete(recursive: true);
} on PathAccessException {
stderr.writeln(
'Failed to delete: ${outputUri.toFilePath()}. '
'The application might be in use.',
);
return 255;
}
}
// Place the bundle in a subdir so that we can potentially put debug symbols
// next to it.
final bundleDirectory = Directory.fromUri(outputUri.resolve('bundle/'));
final binDirectory = Directory.fromUri(bundleDirectory.uri.resolve('bin/'));
await binDirectory.create(recursive: true);
stdout.writeln('Building native assets.');
final packageConfig =
await DartNativeAssetsBuilder.loadPackageConfig(packageConfigUri!);
await DartNativeAssetsBuilder.loadPackageConfig(packageConfigUri);
if (packageConfig == null) {
return compileErrorExitCode;
}
final runPackageName = await DartNativeAssetsBuilder.findRootPackageName(
sourceUri,
executables.first.sourceEntryPoint,
);
final pubspecUri =
pubspecUri ??=
await DartNativeAssetsBuilder.findWorkspacePubspec(packageConfigUri);
final builder = DartNativeAssetsBuilder(
pubspecUri: pubspecUri,
@@ -195,75 +248,89 @@ See documentation on https://dart.dev/interop/c-interop#native-assets.
final tempDir = Directory.systemTemp.createTempSync();
try {
String? recordedUsagesPath;
if (recordUseEnabled) {
recordedUsagesPath = path.join(tempDir.path, 'recorded_usages.json');
}
final generator = KernelGenerator(
genSnapshot: sdk.genSnapshot,
targetDartAotRuntime: sdk.dartAotRuntime,
kind: Kind.exe,
sourceFile: sourceUri.toFilePath(),
outputFile: outputExeUri.toFilePath(),
verbose: verbose,
verbosity: args.option('verbosity')!,
defines: [],
packages: packageConfigUri.toFilePath(),
targetOS: targetOS,
enableExperiment: args.enabledExperiments.join(','),
tempDir: tempDir,
);
final snapshotGenerator = await generator.generate(
recordedUsagesFile: recordedUsagesPath,
);
final linkResult = await builder.linkNativeAssetsAOT(
recordedUsagesPath: recordedUsagesPath,
buildResult: buildResult,
);
if (linkResult == null) {
stderr.writeln('Native assets link failed.');
return 255;
}
final allAssets = [
...buildResult.encodedAssets,
...linkResult.encodedAssets
];
final staticAssets = allAssets
.where((e) => e.isCodeAsset)
.map(CodeAsset.fromEncoded)
.where((e) => e.linkMode == StaticLinking());
if (staticAssets.isNotEmpty) {
stderr.write(
"""'dart build' does not yet support CodeAssets with static linking.
Use linkMode as dynamic library instead.""");
return 255;
}
var first = true;
Uri? nativeAssetsYamlUri;
if (allAssets.isNotEmpty) {
final kernelAssets = await bundleNativeAssets(
allAssets,
builder.target,
binDirectory.uri,
relocatable: true,
verbose: true,
LinkResult? linkResult;
for (final e in executables) {
String? recordedUsagesPath;
if (recordUseEnabled) {
recordedUsagesPath = path.join(tempDir.path, 'recorded_usages.json');
}
final outputExeUri = binDirectory.uri.resolve(
targetOS.executableFileName(e.name),
);
final generator = KernelGenerator(
genSnapshot: sdk.genSnapshot,
targetDartAotRuntime: sdk.dartAotRuntime,
kind: Kind.exe,
sourceFile: e.sourceEntryPoint.toFilePath(),
outputFile: outputExeUri.toFilePath(),
verbose: verbose,
verbosity: verbosity,
defines: [],
packages: packageConfigUri.toFilePath(),
targetOS: targetOS,
enableExperiment: enabledExperiments.join(','),
tempDir: tempDir,
);
nativeAssetsYamlUri =
await writeNativeAssetsYaml(kernelAssets, tempDir.uri);
}
await snapshotGenerator.generate(
nativeAssets: nativeAssetsYamlUri?.toFilePath(),
);
final snapshotGenerator = await generator.generate(
recordedUsagesFile: recordedUsagesPath,
);
if (targetOS == OS.macOS) {
// The dylibs are opened with a relative path to the executable.
// MacOS prevents opening dylibs that are not on the include path.
await rewriteInstallPath(outputExeUri);
if (first) {
// Multiple executables are only supported with recorded uses
// disabled, so don't re-invoke link hooks.
linkResult = await builder.linkNativeAssetsAOT(
recordedUsagesPath: recordedUsagesPath,
buildResult: buildResult,
);
}
if (linkResult == null) {
stderr.writeln('Native assets link failed.');
return 255;
}
final allAssets = [
...buildResult.encodedAssets,
...linkResult.encodedAssets
];
final staticAssets = allAssets
.where((e) => e.isCodeAsset)
.map(CodeAsset.fromEncoded)
.where((e) => e.linkMode == StaticLinking());
if (staticAssets.isNotEmpty) {
stderr.write(
"""'dart build' does not yet support CodeAssets with static linking.
Use linkMode as dynamic library instead.""");
return 255;
}
if (allAssets.isNotEmpty && first) {
// Without tree-shaking, the assets after linking must be identical
// for all entry points.
final kernelAssets = await bundleNativeAssets(
allAssets,
builder.target,
binDirectory.uri,
relocatable: true,
verbose: true,
);
nativeAssetsYamlUri =
await writeNativeAssetsYaml(kernelAssets, tempDir.uri);
}
await snapshotGenerator.generate(
nativeAssets: nativeAssetsYamlUri?.toFilePath(),
);
if (targetOS == OS.macOS) {
// The dylibs are opened with a relative path to the executable.
// MacOS prevents opening dylibs that are not on the include path.
await rewriteInstallPath(outputExeUri);
}
first = false;
}
} finally {
await tempDir.delete(recursive: true);
@@ -277,3 +344,13 @@ extension on String {
String makeFolder() => endsWith('\\') || endsWith('/') ? this : '$this/';
String removeDotDart() => replaceFirst(RegExp(r'\.dart$'), '');
}
/// The executables to build in a `dart build cli` app bundle.
///
/// All entry points must be in the same package.
///
/// The names are typically taken from the `executables` section of the
/// `pubspec.yaml` file.
///
/// Recorded usages and multiple executables are not supported yet.
typedef DartBuildExecutables = List<({String name, Uri sourceEntryPoint})>;
+677
View File
@@ -0,0 +1,677 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:dartdev/src/commands/build.dart';
import 'package:dartdev/src/install/file_system.dart';
import 'package:dartdev/src/install/pub_formats.dart';
import 'package:path/path.dart' as p;
import 'package:pub/pub.dart';
import 'package:pub_formats/pub_formats.dart';
import '../core.dart';
class InstallCommand extends DartdevCommand {
static const cmdName = 'install';
static const cmdDescription =
'''Install or upgrade a Dart CLI tool for global use.
Install all executables specified in a package's pubspec.yaml executables
section (https://dart.dev/tools/pub/pubspec#executables) on the PATH. If the
executables section doesn't exist, installs all `bin/*.dart` entry points as
executables.
If the same package has been previously installed, it will be overwritten.
You can specify three different values for the <package> argument:
1. A package name. This will install the package from pub.dev. (hosted)
The [version-constraint] argument can only be passed to 'hosted'.
2. A git url. This will install the package from a git repository. (git)
3. A path on your machine. This will install the package from that path. (path)''';
static const int genericErrorExitCode = 255;
@override
String get invocation {
final superNoArguments = super.invocation.replaceAll(' [arguments]', '');
return '$superNoArguments <package> [version-constraint]';
}
@override
CommandCategory get commandCategory => CommandCategory.global;
InstallCommand({bool verbose = false})
: super(cmdName, cmdDescription, verbose) {
argParser.addOption(
'git-path',
help: 'Path of git package in repository. '
'Only applies when using a git url for <package>.',
);
argParser.addOption(
'git-ref',
help: 'Git branch or commit to be retrieved. '
'Only applies when using a git url for <package>.',
);
argParser.addFlag(
'overwrite',
negatable: false,
help: 'Overwrite executables from other packages with the same name.',
);
argParser.addOption(
'hosted-url',
abbr: 'u',
help: 'A custom pub server URL for the package. '
'Only applies when using a package name for <package>.',
);
}
/// Parses the arguments.
///
/// Reports usage errors to user if the wrong number or arguments or the wrong
/// flags are passed.
_InstallCommandParsedArguments _parseArguments() {
final argResults = this.argResults!;
final overwrite = argResults.flag('overwrite');
Iterable<String> args = argResults.rest;
String readArg([String error = '']) {
if (args.isEmpty) usageException(error);
final arg = args.first;
args = args.skip(1);
return arg;
}
final argument = readArg('No package source given.');
final sourceKind = _SourceKind.fromArgument(argument);
final gitPath = argResults.option('git-path');
var gitRef = argResults.option('git-ref');
if (sourceKind != _SourceKind.git && (gitPath != null || gitRef != null)) {
usageException(
'Options `--git-path` and `--git-ref` '
'can only be used with a git source.',
);
}
final hostedUrl = argResults.option('hosted-url');
if (sourceKind != _SourceKind.hosted && hostedUrl != null) {
usageException(
'Option `--hosted-url` can only be used with a hosted source.',
);
}
String? versionConstraint;
switch (sourceKind) {
case _SourceKind.git:
case _SourceKind.path:
break;
case _SourceKind.hosted:
versionConstraint = args.isEmpty ? 'any' : readArg();
}
if (args.isNotEmpty) {
usageException(
'Too many arguments, did not expect "${args.join(' ')}"',
);
}
return _InstallCommandParsedArguments._(
source: argument,
sourceKind: sourceKind,
versionConstraint: versionConstraint,
gitPath: gitPath,
gitRef: gitRef,
hostedUrl: hostedUrl,
overwrite: overwrite,
);
}
Future<String> _findPackageName(
_InstallCommandParsedArguments parsedArgs,
) async {
switch (parsedArgs.sourceKind) {
case _SourceKind.git:
return await getPackageNameFromGitRepo(
parsedArgs.source,
ref: parsedArgs.gitRef,
path: parsedArgs.gitPath,
relativeTo: Directory.current.path,
tagPattern: null,
);
case _SourceKind.hosted:
return parsedArgs.source;
case _SourceKind.path:
final pubspecFile = File.fromUri(
Directory(parsedArgs.source).absolute.uri.resolve('pubspec.yaml'));
if (!await pubspecFile.exists()) {
usageException('No pubspec found in ${pubspecFile.path}.');
}
final pubspecYaml = PubspecYamlFile.loadSync(pubspecFile);
return pubspecYaml.name;
}
}
/// Creates a helper package to pull in the requested package as a dependency.
///
/// The user provides us either with (1) a package name plus optional version
/// constraint, (2) a git repo, or (3) a local path. In order to avoid
/// reimplementing pub's knowledge about how to pull in (1) and (2), we create
/// a package with a dependency on the package that the user wants to install.
/// Subsequently, we run `pub get` to let pub pull in dependencies, and we use
/// the `package_graph.json` to find the root of the package that was pulled
/// in by pub.
void _createHelperPackagePubspec({
required _InstallCommandParsedArguments parsedArgs,
required String packageName,
required Directory helperPackageDir,
}) {
final tempPubspec =
File.fromUri(helperPackageDir.uri.resolve('pubspec.yaml'));
final helperPackagePubspec = PubspecYamlFileSyntax(
name: _helperPackageName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
dependencies: {
packageName: switch (parsedArgs.sourceKind) {
_SourceKind.git => GitDependencySourceSyntax(
git: GitSyntax(
url: parsedArgs.source,
path$: parsedArgs.gitPath,
ref: parsedArgs.gitRef,
),
),
_SourceKind.hosted => HostedDependencySourceSyntax(
hosted: parsedArgs.hostedUrl,
version: parsedArgs.versionConstraint!,
),
_SourceKind.path =>
// Re-resolve dependencies for path activate, behave like it would work
// for users of the package if the activate via hosted or git.
PathDependencySourceSyntax(
path$: Directory(parsedArgs.source).absolute.path,
),
}
},
);
helperPackagePubspec.writeSync(tempPubspec);
}
static const _helperPackageName = 'dart_install_helper_package';
Future<void> _resolveHelperPackage(Directory helperPackageDir) async {
try {
await ensurePubspecResolved(helperPackageDir.path);
} on ResolutionFailedException catch (e) {
_installException(e.message);
}
}
/// The executables that should be placed on the user's PATH when this
/// package is installed.
DartBuildExecutables _loadDeclaredExecutables(
File sourcePackagePubspecFile,
Directory sourcePackageRootDirectory,
) {
final pubspecSyntax = PubspecYamlFile.loadSync(sourcePackagePubspecFile);
final errors = pubspecSyntax.validateExecutables();
if (errors.isNotEmpty) {
_installException([
'The pubspec.yaml contains the following errors:',
...errors
].join('\n'));
}
// This is a map of strings to string. Each key is the name of the command
// that will be placed on the user's PATH. The value is the name of the
// .dart script (without extension) in the package's `bin` directory that
// should be run for that command. If the value is null, it defaults to the
// key.
final executablesSyntax = pubspecSyntax.executables;
if (executablesSyntax == null) {
_installException('The pubspec.yaml contained no executables section.');
}
if (executablesSyntax.isEmpty) {
_installException(
'The pubspec.yaml executables section contained no executables.');
}
return [
for (final executable in executablesSyntax.entries)
(
name: executable.key,
sourceEntryPoint: sourcePackageRootDirectory.uri
.resolve('bin/${executable.value ?? executable.key}.dart')
)
];
}
Future<void> _doBuild(
DartBuildExecutables executables,
Directory buildDirectory,
File helperPackageConfigFile,
File sourcePackagePubspecFile,
) async {
// TODO(https://github.com/dart-lang/native/issues/2465): Add a test for
// user-defines in the source package pubspec.
final buildResult = await BuildCliSubcommand.doBuild(
executables: executables,
enabledExperiments: [],
outputUri: buildDirectory.uri,
packageConfigUri: helperPackageConfigFile.uri,
pubspecUri: sourcePackagePubspecFile.uri,
recordUseEnabled: false,
verbose: verbose,
verbosity: 'all',
);
if (buildResult != 0) {
_installException('Build failed.', exitCode: buildResult);
}
}
void _uniinstallAllPackageVersions(String packageName) {
final bundles =
DartInstallDirectory().allAppBundlesSync(packageName: packageName);
try {
for (final bundle in bundles) {
print('Uninstalling ${bundle.directory.path}.');
final links = bundle.executablesOnPathSync;
for (final link in links) {
print('Deleting ${link.entity.path}');
link.deleteSync();
}
print('Deleting ${bundle.directory.path}');
bundle.directory.deleteSync(recursive: true);
}
} on PathAccessException {
_installException('Deletion failed. The application might be in use.');
}
}
AppBundleDirectory _selectAppBundleDirectory(
_InstallCommandParsedArguments parsedArgs,
String packageName,
Directory helperPackageDir,
File helperPackageLockFile,
) {
final AppBundleDirectory outputDir;
switch (parsedArgs.sourceKind) {
case _SourceKind.git:
final resolvedGitRef = parsedArgs.gitRef ??
GitPackageDescriptionSyntax.fromJson(
PubspecLockFile.loadSync(helperPackageLockFile)
.packages![packageName]!
.description
.json,
).resolvedRef;
outputDir = DartInstallDirectory().gitAppBundle(
packageName,
resolvedGitRef,
);
case _SourceKind.hosted:
final packageGraphJson = PackageGraphFile.loadSync(File.fromUri(
helperPackageDir.uri.resolve('.dart_tool/package_graph.json'),
));
final resolvedVersion = packageGraphJson.packages
.firstWhere((e) => e.name == packageName)
.version;
outputDir = DartInstallDirectory().hostedAppBundle(
packageName,
resolvedVersion,
);
case _SourceKind.path:
outputDir = DartInstallDirectory().localAppBundle(packageName);
}
return outputDir;
}
Future<void> _createAppBundleDirectory(
AppBundleDirectory appBundleDirectory,
Directory buildDirectory,
File helperPackageLockFile,
File sourcePackagePubspecFile) async {
if (appBundleDirectory.directory.existsSync()) {
try {
appBundleDirectory.directory.deleteSync(recursive: true);
} on PathAccessException {
_installException(
'Failed to delete: ${appBundleDirectory.directory.path}. '
'The application might be in use.',
);
}
}
appBundleDirectory.directory.createSync(recursive: true);
final bundleDirectory =
Directory.fromUri(buildDirectory.uri.resolve('bundle/'));
await bundleDirectory.rename(
appBundleDirectory.directory.uri.resolve('bundle/').toFilePath());
await helperPackageLockFile.copy(appBundleDirectory.pubspecLock.path);
await sourcePackagePubspecFile.copy(appBundleDirectory.pubspec.path);
}
void _installExecutablesOnPath(
DartBuildExecutables executables,
AppBundleDirectory appBundleDirectory,
String packageName,
_InstallCommandParsedArguments parsedArgs) {
final errors = <String>[];
for (final executable in executables) {
final executableName = executable.name;
final executableFile = appBundleDirectory.executable(executableName);
final executableOnPath =
DartInstallDirectory().bin.executable(executableName);
var createLink = true;
if (executableOnPath.existsSync()) {
final targetExecutable = executableOnPath.targetSync();
final targetPackageName = targetExecutable.appBundle.tryPackageName;
if (targetPackageName == null ||
targetPackageName == packageName ||
parsedArgs.overwrite) {
try {
executableOnPath.deleteSync();
} on PathAccessException {
_installException(
'Failed to delete: ${executableOnPath.entity.path}. '
'The application might be in use.',
);
}
} else {
errors.add(
'Refusing to overwrite executable $executableName from package:$targetPackageName. '
'Pass --overwrite to override.',
);
createLink = false;
}
}
if (createLink) {
executableOnPath.createSync(executableFile);
print('Installed: ${executableOnPath.entity.path}');
}
}
if (errors.isNotEmpty) {
_installException(errors.join('\n'));
}
}
/// Checks to see if the binstubs are on the user's PATH and, if not, suggests
/// that the user add the directory to their PATH.
///
/// [installed] should be the name of an installed executable that can be used
/// to test whether accessing it on the path works.
static void _suggestIfNotOnPath(String installed) {
final binDirPath = DartInstallDirectory().bin.directory.path;
if (Platform.isWindows) {
// See if the shell can find one of the binstubs.
// "\q" means return exit code 0 if found or 1 if not.
final result = Process.runSync('where', [r'\q', '$installed.bat']);
if (result.exitCode == 0) return;
stdout.writeln(
'Warning: Dart installs executables into '
'$binDirPath, which is not on your path.\n'
"You can fix that by adding that directory to your system's "
'"Path" environment variable.\n'
'A web search for "configure windows path" will show you how.',
);
} else {
// See if the shell can find one of the binstubs.
//
// The "command" builtin is more reliable than the "which" executable. See
// http://unix.stackexchange.com/questions/85249/why-not-use-which-what-to-use-then
final result = Process.runSync(
'command',
[
'-v',
installed,
],
runInShell: true,
);
if (result.exitCode == 0) return;
var binDir = binDirPath;
if (binDir.startsWith(Platform.environment['HOME']!)) {
binDir = p.join(
r'$HOME',
p.relative(binDir, from: Platform.environment['HOME']),
);
}
final shellConfigFiles = Platform.isMacOS
// zsh is default on mac - mention that first.
? '(.zshrc, .bashrc, .bash_profile, etc.)'
: '(.bashrc, .bash_profile, .zshrc, etc.)';
stdout.writeln(
"'Warning: Dart installs executables into "
'$binDir, which is not on your path.\n'
"You can fix that by adding this to your shell's config file "
'$shellConfigFiles:\n'
'\n'
' export PATH="\$PATH":"$binDir"\n'
'\n',
);
}
}
@override
Future<int> run() async {
final parsedArgs = _parseArguments();
final packageName = await _findPackageName(parsedArgs);
return await _inTempDir((tempDirectory) async {
try {
final helperPackageDirectory =
Directory.fromUri(tempDirectory.uri.resolve('helperPackage/'));
helperPackageDirectory.createSync();
_createHelperPackagePubspec(
helperPackageDir: helperPackageDirectory,
packageName: packageName,
parsedArgs: parsedArgs,
);
await _resolveHelperPackage(helperPackageDirectory);
final helperPackageLockFile =
File.fromUri(helperPackageDirectory.uri.resolve('pubspec.lock'));
final helperPackageConfigFile = File.fromUri(helperPackageDirectory.uri
.resolve('.dart_tool/package_config.json'));
final sourcePackageRootDirectory = Directory(Uri.parse(
PackageConfigFile.loadSync(helperPackageConfigFile)
.packages
.firstWhere((e) => e.name == packageName)
.rootUri,
).toFilePath())
.ensureEndWithSeparator;
final sourcePackagePubspecFile = File.fromUri(
sourcePackageRootDirectory.uri.resolve('pubspec.yaml'));
final executables = _loadDeclaredExecutables(
sourcePackagePubspecFile,
sourcePackageRootDirectory,
);
final buildDirectory =
Directory.fromUri(tempDirectory.uri.resolve('build/'));
await _doBuild(
executables,
buildDirectory,
helperPackageConfigFile,
sourcePackagePubspecFile,
);
_uniinstallAllPackageVersions(packageName);
AppBundleDirectory appBundleDirectory = _selectAppBundleDirectory(
parsedArgs,
packageName,
helperPackageDirectory,
helperPackageLockFile,
);
await _createAppBundleDirectory(
appBundleDirectory,
buildDirectory,
helperPackageLockFile,
sourcePackagePubspecFile,
);
_installExecutablesOnPath(
executables,
appBundleDirectory,
packageName,
parsedArgs,
);
_suggestIfNotOnPath(executables.first.name);
} on _InstallException catch (e) {
stderr.writeln(e.message);
return genericErrorExitCode;
}
return 0;
});
}
/// Throws a [_InstallException] with [message].
///
/// This enables similar coding style to using [usageException]s.
Never _installException(String message, {int? exitCode}) =>
throw _InstallException(message, exitCode: exitCode);
static Future<T> _inTempDir<T>(
Future<T> Function(Directory tempDirectory) fun) async {
final tempDir = await Directory.systemTemp.createTemp();
// Deal with Windows temp folder aliases.
final tempDirResolved = Directory.fromUri(
Directory(await tempDir.resolveSymbolicLinks()).uri.normalizePath(),
);
try {
return await fun(tempDirResolved);
} finally {
try {
await tempDir.delete(recursive: true);
} on PathAccessException {
if (Platform.isWindows) {
// Don't fail on Windows having files in use.
} else {
rethrow;
}
}
}
}
}
final class _InstallCommandParsedArguments {
final String source;
final _SourceKind sourceKind;
final String? versionConstraint;
final String? gitPath;
final String? gitRef;
final String? hostedUrl;
final bool overwrite;
_InstallCommandParsedArguments._({
required this.source,
required this.sourceKind,
required this.versionConstraint,
required this.gitPath,
required this.gitRef,
required this.hostedUrl,
required this.overwrite,
});
}
enum _SourceKind {
git,
hosted,
path;
static _SourceKind fromArgument(String argument) {
if (_packageNameRegExp.hasMatch(argument)) {
return hosted;
}
final parsedUri = Uri.tryParse(argument);
if (parsedUri != null) {
switch (parsedUri.scheme.toLowerCase()) {
case 'git':
case 'http':
case 'https':
return git;
}
}
final parsedGitSshUrl = _GitSshUrl.tryParse(argument);
if (parsedGitSshUrl != null) {
return git;
}
return path;
}
/// A regular expression matching a Dart identifier.
///
/// This also matches a package name, since they must be Dart identifiers.
static final _identifierRegExp = RegExp(r'[a-zA-Z_]\w*');
/// A regular expression matching allowed package names.
///
/// This allows dot-separated valid Dart identifiers. The dots are there for
/// compatibility with Google's internal Dart packages, but they may not be used
/// when publishing a package to pub.dev.
static final _packageNameRegExp = RegExp(
'^${_identifierRegExp.pattern}(\\.${_identifierRegExp.pattern})*\$',
);
}
// Expected format: git@host:owner/repository.git
class _GitSshUrl {
final String user;
final String host;
final String owner;
final String repository;
final String fullUrl;
_GitSshUrl({
required this.user,
required this.host,
required this.owner,
required this.repository,
required this.fullUrl,
});
static _GitSshUrl? tryParse(String url) {
final regex = RegExp(r'^(\w+)@([^:]+):([^/]+)/(.+?)(?:\.git)?$');
final match = regex.firstMatch(url);
if (match == null) {
return null;
}
return _GitSshUrl(
user: match.group(1)!,
host: match.group(2)!,
owner: match.group(3)!,
repository: match.group(4)!,
fullUrl: url,
);
}
@override
String toString() {
return 'GitSshUrl(user: $user, host: $host, owner: $owner, repository: $repository, fullUrl: $fullUrl)';
}
}
/// An exception during the installation process.
class _InstallException implements Exception {
final String message;
final int? exitCode;
_InstallException(
this.message, {
this.exitCode,
});
@override
String toString() => message;
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:dartdev/src/core.dart';
import 'package:dartdev/src/install/file_system.dart';
import 'package:dartdev/src/install/pub_formats.dart';
import 'package:pub_formats/pub_formats.dart';
class InstalledCommand extends DartdevCommand {
static const cmdName = 'installed';
static const cmdDescription = 'List globally installed Dart CLI tools.';
@override
CommandCategory get commandCategory => CommandCategory.global;
InstalledCommand({bool verbose = false})
: super(cmdName, cmdDescription, verbose) {
argParser.addFlag(
'all',
abbr: 'a',
help: '''Also list packages which are currently not active.
Active package have executables on `PATH`.
App bundles of packages on disk which have no executables
on `PATH` are non-active.''',
);
}
@override
Future<int> run() async {
final argResults = this.argResults!;
final all = argResults.flag('all');
final installedPackages = getInstalledPackages();
for (final package in installedPackages) {
if (package.installed == Installed.not && !all) {
continue;
}
print(package.toString());
}
return 0;
}
static List<InstalledPackage> getInstalledPackages() {
final allAppBundles = DartInstallDirectory().allAppBundlesSync();
final result = <InstalledPackage>[];
for (final appBundleDir in allAppBundles) {
final packageName = appBundleDir.packageName;
final lockFile = appBundleDir.pubspecLock;
final pubspecLock = PubspecLockFile.loadSync(lockFile);
final lockInfo = pubspecLock.packages!.entries
.where((entry) =>
entry.value.dependency == DependencyTypeSyntax.directMain)
.single
.value;
final binaries = appBundleDir.executablesSync;
var foundBinary = false;
var missingBinary = false;
for (final binary in binaries) {
final link = binary.onPath;
if (!link.existsSync()) {
missingBinary = true;
} else {
if (link.targetSync().equals(binary)) {
foundBinary = true;
} else {
missingBinary = true;
}
}
}
final lastModified = lockFile.lastModifiedSync();
result.add(InstalledPackage(
name: packageName,
appBundle: appBundleDir.directory,
installed: switch ((foundBinary, missingBinary)) {
(_, false) => Installed.fully,
(true, true) => Installed.partial,
(false, true) => Installed.not,
},
lockInfo: lockInfo,
lastModified: lastModified,
));
}
return result;
}
}
class InstalledPackage {
final String name;
final Directory appBundle;
final Installed installed;
final PackageSyntax lockInfo;
final DateTime lastModified;
InstalledPackage({
required this.appBundle,
required this.installed,
required this.lastModified,
required this.lockInfo,
required this.name,
});
@override
String toString() {
var result = '$name ${lockInfo.version}';
switch (lockInfo.source) {
case PackageSourceSyntax.git:
final description =
GitPackageDescriptionSyntax.fromJson(lockInfo.description.json);
final url = description.url;
final resolvedRef = description.resolvedRef.substring(0, 8);
result += ' from Git repository "$url" at "$resolvedRef"';
case PackageSourceSyntax.hosted:
break;
case PackageSourceSyntax.path$:
final description =
PathPackageDescriptionSyntax.fromJson(lockInfo.description.json);
final path = description.path$;
result += ' from "$path" at $lastModified';
default:
result += ' from an unknown source "${lockInfo.source.name}"';
}
switch (installed) {
case Installed.fully:
break;
case Installed.partial:
result += ' (partially active)';
case Installed.not:
result += ' (not active)';
}
return result;
}
}
enum Installed {
fully,
partial,
not;
}
@@ -0,0 +1,65 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:dartdev/src/core.dart';
import 'package:dartdev/src/install/file_system.dart';
class UninstallCommand extends DartdevCommand {
static const cmdName = 'uninstall';
static const cmdDescription = '''Remove a globally installed Dart CLI tool.
Completely deletes all installed versions of <package> and all executables from
<package> placed on PATH.''';
@override
String get invocation {
final superNoArguments = super.invocation.replaceAll(' [arguments]', '');
return '$superNoArguments <package>';
}
@override
CommandCategory get commandCategory => CommandCategory.global;
UninstallCommand({bool verbose = false})
: super(cmdName, cmdDescription, verbose);
@override
Future<int> run() async {
final argResults = this.argResults!;
Iterable<String> args = argResults.rest;
if (args.length != 1) {
final arguments = args.isEmpty ? 'none' : '"${args.join(' ')}"';
usageException(
'Wrong number of arguments, expected "<package>", got $arguments.',
);
}
final package = args.single;
final bundles =
DartInstallDirectory().allAppBundlesSync(packageName: package);
if (bundles.isEmpty) {
print('Did not find any packages named "$package".');
return 255;
}
try {
for (final bundle in bundles) {
final links = bundle.executablesOnPathSync;
for (final link in links) {
print('Deleting ${link.entity.path}');
link.deleteSync();
}
print('Deleting ${bundle.directory.path}');
bundle.directory.deleteSync(recursive: true);
}
} on PathAccessException {
stderr.writeln('Deletion failed. The application might be in use.');
return 255;
}
return 0;
}
}
+1
View File
@@ -83,6 +83,7 @@ abstract class DartdevCommand extends Command<int> {
}
enum CommandCategory {
global('Global'),
project('Project'),
sourceCode('Source code'),
tools('Tools');
@@ -0,0 +1,318 @@
// Copyright (c) 2025, 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.
/// The directory and file structure for `dart install` on the host machine.
library;
import 'dart:io';
import 'package:dart_data_home/dart_data_home.dart';
import 'package:dartdev/src/utils.dart';
/// The root directory for Dart installations.
///
/// This directory contains various subdirectories for binaries and app bundles.
///
/// <pre>
/// [DartInstallDirectory]
/// ├── [bin]/
/// │ └── (executables)
/// └── app-bundles/
/// └── (packageName)/
/// ├── git/
/// │ └── (gitHash)/
/// │ └── [AppBundleDirectory] (e.g., 'my_package/git/abcdef123/')
/// ├── hosted/
/// │ └── (version)/
/// │ └── [AppBundleDirectory] (e.g., 'my_package/hosted/1.0.0/')
/// └── local/
/// └── [AppBundleDirectory] (e.g., 'my_package/local/')
/// </pre>
extension type DartInstallDirectory._(Directory directory) {
static final DartInstallDirectory _singleton =
DartInstallDirectory._(Directory(getDartDataHome('install')));
factory DartInstallDirectory() {
return _singleton;
}
BinOnPathDirectory get bin => BinOnPathDirectory._(
Directory.fromUri(
directory.uri.resolve('bin/'),
),
);
Directory get _appBundles =>
Directory.fromUri(directory.uri.resolve('app-bundles/'));
AppBundleDirectory gitAppBundle(
String packageName,
String gitHash,
) =>
AppBundleDirectory._(
Directory.fromUri(
_appBundles.uri.resolve('$packageName/git/$gitHash/'),
),
);
AppBundleDirectory hostedAppBundle(
String packageName,
String version,
) =>
AppBundleDirectory._(
Directory.fromUri(
_appBundles.uri.resolve('$packageName/hosted/$version/'),
),
);
AppBundleDirectory localAppBundle(
String packageName,
) =>
AppBundleDirectory._(
Directory.fromUri(
_appBundles.uri.resolve('$packageName/local/'),
),
);
List<AppBundleDirectory> allAppBundlesSync({String? packageName}) {
final dartInstallAppbundlesDir = _appBundles;
if (!dartInstallAppbundlesDir.existsSync()) {
return [];
}
final packageDirs =
dartInstallAppbundlesDir.listSync().whereType<Directory>();
final result = <AppBundleDirectory>[];
for (final packageDir in packageDirs) {
if (packageName != null && packageDir.name != packageName) {
continue;
}
final gitDir = Directory.fromUri(packageDir.uri.resolve('git/'));
final hostedDir = Directory.fromUri(packageDir.uri.resolve('hosted/'));
final localDir = Directory.fromUri(packageDir.uri.resolve('local/'));
if (gitDir.existsSync()) {
result.addAll(
gitDir
.listSync()
.whereType<Directory>()
.map((d) => AppBundleDirectory._(d.ensureEndWithSeparator)),
);
}
if (hostedDir.existsSync()) {
result.addAll(
hostedDir
.listSync()
.whereType<Directory>()
.map((d) => AppBundleDirectory._(d.ensureEndWithSeparator)),
);
}
if (localDir.existsSync()) {
result.add(AppBundleDirectory._(localDir.ensureEndWithSeparator));
}
}
return result;
}
}
/// The directory that contains all executables available on `PATH`.
///
/// <pre>
/// [BinOnPathDirectory]
/// └── [executable]s
/// </pre>
extension type BinOnPathDirectory._(Directory directory) {
/// An executable with [name] in the bin directory.
///
/// The parameter [name] must not contain an extension.
ExecutableOnPath executable(String name) {
if (Platform.isLinux || Platform.isMacOS) {
return ExecutableOnPath._unix(Link.fromUri(directory.uri.resolve(name)));
}
if (Platform.isWindows) {
return ExecutableOnPath._windows(
File.fromUri(directory.uri.resolve('$name.bat')));
}
throw UnsupportedError('Unsupported OS: ${Platform.operatingSystem}.');
}
}
/// An executable in [BinOnPathDirectory] available on `PATH`.
///
/// [entity] is a [Link] on Linux and MacOS, and a [File] on Windows.
extension type ExecutableOnPath._(FileSystemEntity entity) {
factory ExecutableOnPath._unix(Link link) => ExecutableOnPath._(link);
Link get unix {
if (Platform.isLinux || Platform.isMacOS) {
return entity as Link;
}
throw UnsupportedError('Wrong OS: ${Platform.operatingSystem}.');
}
factory ExecutableOnPath._windows(File file) => ExecutableOnPath._(file);
File get windows {
if (Platform.isWindows) {
return entity as File;
}
throw UnsupportedError('Wrong OS: ${Platform.operatingSystem}.');
}
bool existsSync() => entity.existsSync();
void deleteSync() => entity.deleteSync();
static const _marker = 'target_file_path_marker';
void createSync(ExecutableInBundle target) {
if (Platform.isLinux || Platform.isMacOS) {
return unix.createSync(target.file.path, recursive: true);
}
if (Platform.isWindows) {
final wrapperScriptContents = '''
@ECHO OFF
REM $_marker
"${target.file.path}" %*
EXIT /B %ERRORLEVEL%
''';
if (!windows.existsSync()) {
windows.createSync(recursive: true);
}
return windows.writeAsStringSync(wrapperScriptContents);
}
throw UnsupportedError('Unsupported OS: ${Platform.operatingSystem}.');
}
ExecutableInBundle targetSync() {
if (Platform.isLinux || Platform.isMacOS) {
return ExecutableInBundle._(File(unix.targetSync()));
}
if (Platform.isWindows) {
final wrapperScriptContents = windows.readAsStringSync();
final iterator = wrapperScriptContents.split('\n').iterator..moveNext();
while (!iterator.current.contains(_marker)) {
iterator.moveNext();
}
iterator.moveNext();
final line = iterator.current;
final path = line.split('"')[1];
return ExecutableInBundle._(File(path));
}
throw UnsupportedError('Unsupported OS: ${Platform.operatingSystem}.');
}
bool equals(ExecutableOnPath other) => entity.path == other.entity.path;
}
/// A directory containing an app bundle and its installation data.
///
/// This directory is structured as follows:
///
/// <pre>
/// [AppBundleDirectory]
/// ├── bundle/ (Contains the application code and assets)
/// │ ├── bin/ (Executables that can be run directly)
/// │ │ └── [ExecutableInBundle] (Specific executable files for the app bundle)
/// │ └── lib/ (Dynamic libraries required by the executables)
/// │ └── (dynamic libraries) (Platform-specific shared libraries)
/// ├── pubspec.lock (Generated by pub, locks package dependencies to specific versions)
/// └── pubspec.yaml (Declares project dependencies and metadata)
/// </pre>
extension type AppBundleDirectory._(Directory directory) {
String get packageName {
final result = tryPackageName;
if (result != null) {
return result;
}
throw StateError('${directory.path} is not a valid app bundle directory.');
}
String? get tryPackageName {
if (!directory.path.startsWith(DartInstallDirectory()._appBundles.path)) {
throw StateError(
'${directory.path} does not start with ${DartInstallDirectory()._appBundles.path}.',
);
// return null;
}
final relativeSegments = directory.uri.pathSegments
.skip(DartInstallDirectory()
._appBundles
.uri
.pathSegments
.where((e) => e.isNotEmpty)
.length)
.toList();
if (relativeSegments.length < 2) {
throw StateError(
'$directory, $relativeSegments does not contain at least two path segments.',
);
// return null;
}
if (relativeSegments[1] != 'hosted' &&
relativeSegments[1] != 'git' &&
relativeSegments[1] != 'local') {
throw StateError(
'$directory, $relativeSegments, ${relativeSegments[1]} is not hosted, git or local.',
);
// return null;
}
return relativeSegments[0];
}
Directory get _binDirectory =>
Directory.fromUri(directory.uri.resolve('bundle/bin/'));
List<ExecutableInBundle> get executablesSync {
final binaries = _binDirectory
.listSync()
.whereType<File>()
.map((e) => ExecutableInBundle._(e))
.toList();
return binaries;
}
/// The executables from this bundle which are available on `PATH`.
List<ExecutableOnPath> get executablesOnPathSync {
final result = <ExecutableOnPath>[];
for (final executable in executablesSync) {
final onPath = executable.onPath;
if (onPath.existsSync() && onPath.targetSync().equals(executable)) {
result.add(onPath);
}
}
return result;
}
/// An executable with [name] in the app bundle.
///
/// The parameter [name] most not contain an extension.
ExecutableInBundle executable(String name) {
return ExecutableInBundle._(File.fromUri(
_binDirectory.uri.resolve(
Platform.isWindows ? '$name.exe' : name,
),
));
}
File get pubspec => File.fromUri(directory.uri.resolve('pubspec.yaml'));
File get pubspecLock => File.fromUri(directory.uri.resolve('pubspec.lock'));
}
/// An executable inside an [AppBundleDirectory].
extension type ExecutableInBundle._(File file) {
AppBundleDirectory get appBundle {
return AppBundleDirectory._(Directory.fromUri(
file.uri.resolve('../../'),
));
}
ExecutableOnPath get onPath =>
DartInstallDirectory().bin.executable(file.basenameWithoutExtension);
bool equals(ExecutableInBundle other) => file.path == other.file.path;
}
extension DirectoryExtension on Directory {
Directory get ensureEndWithSeparator => Directory.fromUri(uri);
}
@@ -0,0 +1,75 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert' hide json;
import 'dart:io';
import 'package:pub_formats/pub_formats.dart';
import 'package:yaml/yaml.dart';
extension PubspecYamlFile on PubspecYamlFileSyntax {
static PubspecYamlFileSyntax loadSync(File file) {
return PubspecYamlFileSyntax.fromJson(
_convertYamlMapToJsonMap(
loadYamlDocument(file.readAsStringSync()).contents as YamlMap,
),
);
}
void writeSync(File file) {
// JSON is valid YAML, and JSON encoding is much faster, write JSON.
return file.writeAsStringSync(_jsonEncoder.convert(json));
}
}
extension PubspecLockFile on PubspecLockFileSyntax {
static PubspecLockFileSyntax loadSync(File file) {
return PubspecLockFileSyntax.fromJson(
_convertYamlMapToJsonMap(
loadYamlDocument(file.readAsStringSync()).contents as YamlMap,
),
);
}
}
extension PackageGraphFile on PackageGraphFileSyntax {
static PackageGraphFileSyntax loadSync(File file) {
return PackageGraphFileSyntax.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, Object?>,
);
}
}
extension PackageConfigFile on PackageConfigFileSyntax {
static PackageConfigFileSyntax loadSync(File file) {
return PackageConfigFileSyntax.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, Object?>,
);
}
}
final _jsonEncoder = JsonEncoder.withIndent(' ');
Map<String, Object?> _convertYamlMapToJsonMap(YamlMap yamlMap) {
final Map<String, Object?> jsonMap = {};
yamlMap.forEach((key, value) {
if (key is! String) {
throw UnsupportedError(
'YAML map keys must be strings for JSON conversion.');
}
jsonMap[key] = _convertYamlValue(value);
});
return jsonMap;
}
Object? _convertYamlValue(dynamic yamlValue) {
if (yamlValue is YamlMap) {
return _convertYamlMapToJsonMap(yamlValue);
} else if (yamlValue is YamlList) {
return yamlValue.map((e) => _convertYamlValue(e)).toList();
} else {
// For primitive types: String, int, double, bool, null.
return yamlValue;
}
}
+2
View File
@@ -132,6 +132,8 @@ String trimEnd(String s, String? suffix) {
extension FileSystemEntityExtension on FileSystemEntity {
String get name => p.basename(path);
String get basenameWithoutExtension => p.basenameWithoutExtension(path);
bool get isDartFile => this is File && p.extension(path) == '.dart';
}
+2
View File
@@ -18,6 +18,7 @@ dependencies:
code_assets: any
collection: any
dart2native: any
dart_data_home: any
dart_mcp_server: any
dart_style: any
dartdoc: any
@@ -37,6 +38,7 @@ dependencies:
package_config: any
path: any
pub: any
pub_formats: any
unified_analytics: any
vm: any
vm_service: any
+5
View File
@@ -79,6 +79,11 @@ void help() {
'''
Available commands:
Global
install Install or upgrade a Dart CLI tool for global use.
installed List globally installed Dart CLI tools.
uninstall Remove a globally installed Dart CLI tool.
Project
build Build a Dart application including native assets.
compile Compile Dart to various formats.
+13 -1
View File
@@ -16,6 +16,8 @@ import 'package:yaml_edit/yaml_edit.dart';
import '../utils.dart';
export 'package:hooks_runner/src/utils/run_process.dart' show RunProcessResult;
extension UriExtension on Uri {
Uri get parent {
return File(toFilePath()).parent.uri;
@@ -34,7 +36,15 @@ Future<void> inTempDir(Future<void> Function(Uri tempUri) fun) async {
} finally {
if (!Platform.environment.containsKey(keepTempKey) ||
Platform.environment[keepTempKey]!.isEmpty) {
await tempDir.delete(recursive: true);
try {
await tempDir.delete(recursive: true);
} on PathAccessException {
if (Platform.isWindows) {
// Don't fail on files being in use.
} else {
rethrow;
}
}
}
}
}
@@ -310,12 +320,14 @@ Future<run_process.RunProcessResult> runDart({
Uri? workingDirectory,
required Logger? logger,
bool expectExitCodeZero = true,
Map<String, String>? environment,
}) async {
final result = await runProcess(
executable: dartExecutable,
arguments: arguments,
workingDirectory: workingDirectory,
logger: logger,
environment: environment,
);
if (expectExitCodeZero) {
if (result.exitCode != 0) {
@@ -0,0 +1,850 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:pub_formats/pub_formats.dart';
import 'package:test/test.dart';
import '../utils.dart';
import 'helpers.dart';
/// A package in the Dart SDK that we use for testing.
///
/// This package is on pub.dev, and on a Git repo, and on disk. So, this can be
/// used for testing hosted, git, and path installs.
///
/// Moreover it has an executables section in the pubspec.
const _packageForTest = 'vm_snapshot_analysis';
/// A valid version for [_packageForTest].
///
/// Not the newest version.
const _packageVersion = '0.7.5';
/// The name of an executable in [_packageForTest].
const _cliToolForTest = 'snapshot_analysis';
final _pathEnvVarSeparator = Platform.isWindows ? ';' : ':';
/// A package not in the Dart SDK repo. The Dart SDK repo takes too long to
/// clone.
const _gitPackageForTest = 'dart_app';
final _gitHttpsUrl = Uri.parse('https://dart.googlesource.com/native');
const _gitPath = 'pkgs/hooks_runner/test_data/dart_app/';
const _gitRef = '8ce789991cddb8864b0f3fa210c83d3d10e78316';
const String _dartDirectoryEnvKey = 'DART_DATA_HOME';
final _dartDevEntryScriptUri = resolveDartDevUri('bin/dartdev.dart');
final _sdkUri = resolveDartDevUri('.').resolve('../../');
final _packageRelativePath = Uri.directory('pkg/vm_snapshot_analysis/');
final _packageDir = Directory.fromUri(
_sdkUri.resolveUri(_packageRelativePath),
);
void main([List<String> args = const []]) async {
if (!nativeAssetsExperimentAvailableOnCurrentChannel) {
return;
}
final bool fromDartdevSource = args.contains('--source');
final errorExitCode = fromDartdevSource
? /* Dartdev doesn't exit the process, it sends a message to the VM.*/ 0
: 255;
final argsFiltered = args.where((e) => e != '--source').toList();
final testName = argsFiltered.isEmpty ? null : argsFiltered.join(' ');
@isTest
void skippableTest(
String description,
dynamic Function() body, {
Timeout? timeout,
}) {
test(
description,
skip: !(testName == null || description.contains(testName)),
timeout: timeout,
body,
);
}
final commandsHelpmessages = [
(
'install',
'''
Install or upgrade a Dart CLI tool for global use.
Install all executables specified in a package's pubspec.yaml executables
section (https://dart.dev/tools/pub/pubspec#executables) on the PATH. If the
executables section doesn't exist, installs all `bin/*.dart` entry points as
executables.
If the same package has been previously installed, it will be overwritten.
You can specify three different values for the <package> argument:
1. A package name. This will install the package from pub.dev. (hosted)
The [version-constraint] argument can only be passed to 'hosted'.
2. A git url. This will install the package from a git repository. (git)
3. A path on your machine. This will install the package from that path. (path)
Usage: dart install <package> [version-constraint]
-h, --help Print this usage information.
--git-path Path of git package in repository. Only applies when using a git url for <package>.
--git-ref Git branch or commit to be retrieved. Only applies when using a git url for <package>.
--overwrite Overwrite executables from other packages with the same name.
-u, --hosted-url A custom pub server URL for the package. Only applies when using a package name for <package>.
Run "dart help" to see global options.
'''
),
(
'installed',
'''
List globally installed Dart CLI tools.
Usage: dart installed [arguments]
-h, --help Print this usage information.
-a, --[no-]all Also list packages which are currently not active.
Active package have executables on `PATH`.
App bundles of packages on disk which have no executables
on `PATH` are non-active.
Run "dart help" to see global options.
'''
),
(
'uninstall',
'''
Remove a globally installed Dart CLI tool.
Completely deletes all installed versions of <package> and all executables from
<package> placed on PATH.
Usage: dart uninstall <package>
-h, --help Print this usage information.
Run "dart help" to see global options.
'''
),
];
for (final (command, helpMessage) in commandsHelpmessages) {
skippableTest('dart $command --help', timeout: longTimeout, () async {
final result = await _runDartdev(
fromDartdevSource,
command,
['--help'],
null,
{},
);
expect(result.stdout, contains(helpMessage));
});
}
final argumentss = [
(
null,
[_packageForTest],
),
(
null,
[_packageForTest, _packageVersion],
),
(
null,
[_packageForTest, _packageVersion, '--hosted-url', 'https://pub.dev/'],
),
(
null,
[_packageDir.path],
),
(
_sdkUri,
[_packageRelativePath.path],
),
(
_packageDir.uri,
['.'],
),
];
for (final (workingDirectory, arguments) in argumentss) {
var testName = arguments.join(' ');
if (workingDirectory != null) {
testName += ' in ${workingDirectory.toFilePath()}';
}
skippableTest('dart install $testName', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await _runDartdev(
fromDartdevSource,
'install',
arguments,
workingDirectory,
environment,
);
await _runToolForTest(environment);
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[],
null,
environment,
);
final installedLines = installedResult.stdout.split('\n');
expect(installedLines.where((e) => e.isNotEmpty).length, equals(1));
final installedLine = installedLines.first;
expect(
installedLine,
startsWith(_packageForTest),
);
if (arguments.contains(_packageVersion)) {
expect(
installedLine,
equals('$_packageForTest $_packageVersion'),
);
}
if (arguments.contains(_packageRelativePath.toString())) {
expect(
installedLine,
stringContainsInOrder([_packageRelativePath.toString(), '" at 20']),
);
}
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
);
});
});
}
final argumentssGit = [
(
null,
[_gitHttpsUrl.toString(), '--git-path', _gitPath],
),
(
null,
[_gitHttpsUrl.toString(), '--git-path', _gitPath, '--git-ref', _gitRef],
),
];
for (final (workingDirectory, arguments) in argumentssGit) {
var testName = arguments.join(' ');
if (workingDirectory != null) {
testName += ' in ${workingDirectory.toFilePath()}';
}
skippableTest('dart install $testName', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await _runDartdev(
fromDartdevSource,
'install',
arguments,
workingDirectory,
environment,
);
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[],
null,
environment,
);
final installedLines = installedResult.stdout.split('\n');
expect(installedLines.where((e) => e.isNotEmpty).length, equals(1));
final installedLine = installedLines.first;
expect(
installedLine,
startsWith(_gitPackageForTest),
);
expect(
installedLine,
contains(' from Git repository "${_gitHttpsUrl.toString()}"'),
);
if (arguments.contains('--git-ref')) {
expect(
installedLine,
contains(' at "${_gitRef.substring(0, 8)}"'),
);
}
await _runDartdev(
fromDartdevSource,
'uninstall',
[_gitPackageForTest],
null,
environment,
);
});
});
}
skippableTest('dart install ~/.dart/install/bin/ not on PATH', () async {
await inTempDir((tempUri) async {
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
};
await inTempDir((tempUri) async {
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[_packageForTest],
null,
environment,
);
if (Platform.isWindows) {
expect(
installResult.stdout,
stringContainsInOrder([
'Warning: Dart installs executables into ',
'which is not on your path.',
"You can fix that by adding that directory to your system's ",
'"Path" environment variable.',
'A web search for "configure windows path" will show you how.',
]),
);
} else {
expect(
installResult.stdout,
stringContainsInOrder([
'Warning: Dart installs executables into',
'You can fix that by adding this to your shell\'s config file ',
'export PATH="\$PATH":',
]),
);
}
});
});
});
skippableTest('dart install dart_app (with build hooks and code assets)',
timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await nativeAssetsTest('dart_app', (dartAppUri) async {
// Add a second executable.
final entryPoint1 =
File.fromUri(dartAppUri.resolve('bin/dart_app.dart'));
final entryPoint2 =
File.fromUri(dartAppUri.resolve('bin/dart_app_copy.dart'));
final entryPoint1Contents = await entryPoint1.readAsString();
final entryPoint2Contents = entryPoint1Contents.replaceAll('5', '42');
await entryPoint2.writeAsString(entryPoint2Contents);
final pubspecFile = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
final pubspecOld =
pubspecFile.readAsStringSync().replaceAll('\r\n', '\n');
final pubspecNew = pubspecOld.replaceAll(
'''executables:
dart_app:'''
.replaceAll('\r\n', '\n'),
'''executables:
dart_app:
dart_app_copy:'''
.replaceAll('\r\n', '\n'),
);
expect(pubspecNew, isNot(equals(pubspecOld)));
pubspecFile.writeAsStringSync(pubspecNew);
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
for (final (tool, someInt) in [
('dart_app', 5),
('dart_app_copy', 42)
]) {
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(tool),
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
logger: logger,
environment: environment,
);
expect(
toolResult.stdout,
stringContainsInOrder([
'add($someInt, 6) = ${someInt + 6}',
'subtract($someInt, 6) = ${someInt - 6}',
]),
);
expect(toolResult.exitCode, 0);
}
});
});
});
skippableTest('dart install --overwrite', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
await nativeAssetsTest('dart_app', (dartAppUri) async {
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
// Not overwriting, but the same package is fine.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
final pubspecFile = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
final pubspecContents = await pubspecFile.readAsString();
final pubspecContentsNew =
pubspecContents.replaceFirst('dart_app', 'a_different_name');
await pubspecFile.writeAsString(pubspecContentsNew);
// Trying to install an executable with the same name from a different
// package should fail.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
expectedExitCode: errorExitCode,
);
// Overwriting is fine.
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath(), '--overwrite'],
null,
environment,
);
// Using --overwrite leads to inactive versions.
// `dart installed --all` should also report the non-active versions.
for (final all in [true, false]) {
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
[if (all) '--all'],
null,
environment,
);
final installedLines = installedResult.stdout
.split('\n')
.where((e) => e.isNotEmpty)
.toList();
if (all) {
expect(installedLines, hasLength(2));
expect(
installedLines,
contains(startsWith('dart_app')),
);
} else {
expect(installedLines, hasLength(1));
expect(
installedLines,
isNot(contains(startsWith('dart_app'))),
);
}
}
});
});
});
skippableTest('dart install check exit codes', timeout: longTimeout,
() async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const appName = 'test_app';
final dartAppUri = tempUri.resolve('$appName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: appName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
appName: appName,
},
).json));
final mainFile = File.fromUri(dartAppUri.resolve('bin/$appName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
import 'dart:io';
void main(List<String> args) {
exit(int.parse(args.first));
}
''');
await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
);
const testExitCode = 55;
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(appName),
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
arguments: ['$testExitCode'],
logger: logger,
environment: environment,
expectedExitCode: testExitCode,
);
expect(toolResult.exitCode, testExitCode);
});
});
skippableTest('dart install hooks user-defines and failures',
timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const packageName = 'test_app';
final dartAppUri = tempUri.resolve('$packageName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
final mainFile =
File.fromUri(dartAppUri.resolve('bin/$packageName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
void main(List<String> args) { }
''');
final buildHookFile = File.fromUri(dartAppUri.resolve('hook/build.dart'));
await buildHookFile.create(recursive: true);
buildHookFile.writeAsString('''
import 'package:hooks/hooks.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
final myUserDefine = input.userDefines['my_user_define'];
if (myUserDefine == null) {
throw Exception('Expected a user define');
}
});
}
''');
for (final addUserDefine in [true, false]) {
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: packageName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
packageName: packageName,
},
dependencies: {
'hooks': PathDependencySourceSyntax(
path$: sdkRootUri
.resolve('third_party/pkg/native/pkgs/hooks/')
.toFilePath(),
),
},
hooks: HooksSyntax(
userDefines: {
packageName: {
if (addUserDefine) 'my_user_define': 'a_value,',
},
},
),
).json));
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[dartAppUri.toFilePath()],
null,
environment,
expectedExitCode: addUserDefine ? 0 : errorExitCode,
);
if (addUserDefine) {
expect(installResult.exitCode, equals(0));
expect(installResult.stderr, isEmpty);
} else {
// Check that build hook failures are surfaced and that error messages
// are visible.
expect(installResult.exitCode, equals(errorExitCode));
expect(installResult.stderr, contains('Expected a user define'));
}
}
});
});
skippableTest('dart install uninstalls old versions', timeout: longTimeout,
() async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
// Install two versions.
await _runDartdev(
fromDartdevSource,
'install',
['.'],
_packageDir.uri,
environment,
);
final installResult = await _runDartdev(
fromDartdevSource,
'install',
[_packageForTest, _packageVersion],
null,
environment,
);
expect(
installResult.stdout,
stringContainsInOrder(['Uninstalling ', _packageForTest]),
);
// `--all` should also report the non-active versions.
Future<List<String>> runInstalled() async {
final installedResult = await _runDartdev(
fromDartdevSource,
'installed',
['--all'],
null,
environment,
);
final installedLines = installedResult.stdout
.split('\n')
.where((e) => e.isNotEmpty)
.toList();
return installedLines;
}
expect(await runInstalled(), hasLength(1));
// `uninstall` uninstalls all versions.
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
);
expect(await runInstalled(), hasLength(0));
});
});
skippableTest('dart uninstall', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
};
// `uninstall` should have a non-zero exit if nothing was uninstalled.
await _runDartdev(
fromDartdevSource,
'uninstall',
[_packageForTest],
null,
environment,
expectedExitCode: errorExitCode,
);
});
});
skippableTest('dart uninstall while running', timeout: longTimeout, () async {
await inTempDir((tempUri) async {
final binDir = Directory.fromUri(tempUri.resolve('install/bin'));
final environment = {
_dartDirectoryEnvKey: tempUri.toFilePath(),
'PATH':
'${binDir.path}$_pathEnvVarSeparator${Platform.environment['PATH']!}',
};
const packageName = 'test_app';
final dartAppUri = tempUri.resolve('$packageName/');
final pubspec = File.fromUri(dartAppUri.resolve('pubspec.yaml'));
await pubspec.create(recursive: true);
await pubspec.writeAsString(jsonEncode(PubspecYamlFileSyntax(
name: packageName,
environment: EnvironmentSyntax(
sdk: '^${Platform.version.split(' ').first}',
),
executables: {
packageName: packageName,
},
).json));
final mainFile =
File.fromUri(dartAppUri.resolve('bin/$packageName.dart'));
await mainFile.create(recursive: true);
mainFile.writeAsString('''
void main(List<String> args) async {
await Future.delayed(Duration(days: 1000000));
}
''');
Future<RunProcessResult> doInstall(int expectedExitCode) async {
return await _runDartdev(fromDartdevSource, 'install',
[dartAppUri.toFilePath()], null, environment,
expectedExitCode: expectedExitCode);
}
await doInstall(0);
final runningProcess = await Process.start(
packageName,
[],
environment: environment,
runInShell: true,
);
final installWhileRunningResult = await doInstall(
Platform.isWindows ? errorExitCode : 0,
);
if (Platform.isWindows) {
expect(
installWhileRunningResult.stderr,
contains('The application might be in use.'),
);
} else {
expect(installWhileRunningResult.stderr, isEmpty);
}
final uninstallWhileRunningResult = await _runDartdev(
fromDartdevSource,
'uninstall',
[packageName],
null,
environment,
expectedExitCode: Platform.isWindows ? errorExitCode : 0,
);
if (Platform.isWindows) {
expect(
uninstallWhileRunningResult.stderr,
contains('The application might be in use.'),
);
} else {
expect(uninstallWhileRunningResult.stderr, isEmpty);
}
runningProcess.kill();
});
});
}
Future<RunProcessResult> _runDartdev(
bool fromDartdevSource,
String command,
List<String> arguments,
Uri? workingDirectory,
Map<String, String> environment, {
int expectedExitCode = 0,
}) async {
final installResult = await runDart(
arguments: [
if (fromDartdevSource) _dartDevEntryScriptUri.toFilePath(),
command,
...arguments,
],
workingDirectory: workingDirectory,
logger: logger,
environment: environment,
expectExitCodeZero: false,
);
expect(installResult.exitCode, equals(expectedExitCode));
return installResult;
}
/// Runs [_cliToolForTest] and expects the help message.
Future<RunProcessResult> _runToolForTest(
Map<String, String> environment,
) async {
final toolResult = await runProcess(
// Note this has `runInShell: true` under it to ensure PATHEXT is used on
// Windows so that invoking an executable without extension works.
executable: Uri.file(_cliToolForTest),
arguments: ['--help'],
// Run in some unrelated directory ensuring PATH is picked up.
workingDirectory: Directory.systemTemp.uri,
logger: logger,
environment: environment,
);
expect(
toolResult.stdout,
stringContainsInOrder([
'Tools for binary size analysis of Dart VM AOT snapshots.',
]),
);
expect(toolResult.exitCode, 0);
return toolResult;
}
+2
View File
@@ -224,6 +224,8 @@ dependency_overrides:
path: third_party/pkg/protobuf/protoc_plugin
pub:
path: third_party/pkg/pub
pub_formats:
path: third_party/pkg/native/pkgs/pub_formats
pub_semver:
path: third_party/pkg/tools/pkgs/pub_semver
regression_tests: