From 94894b1796bba61cd3c0f7d73e2ac3f9321fd824 Mon Sep 17 00:00:00 2001 From: Daco Harkes Date: Wed, 13 Aug 2025 01:33:59 -0700 Subject: [PATCH] [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 Commit-Queue: Daco Harkes Reviewed-by: Michael Goderbauer --- pkg/dartdev/lib/dartdev.dart | 8 + pkg/dartdev/lib/src/commands/build.dart | 253 ++++-- pkg/dartdev/lib/src/commands/install.dart | 677 ++++++++++++++ pkg/dartdev/lib/src/commands/installed.dart | 142 +++ pkg/dartdev/lib/src/commands/uninstall.dart | 65 ++ pkg/dartdev/lib/src/core.dart | 1 + pkg/dartdev/lib/src/install/file_system.dart | 318 +++++++ pkg/dartdev/lib/src/install/pub_formats.dart | 75 ++ pkg/dartdev/lib/src/utils.dart | 2 + pkg/dartdev/pubspec.yaml | 2 + pkg/dartdev/test/commands/help_test.dart | 5 + pkg/dartdev/test/native_assets/helpers.dart | 14 +- .../test/native_assets/install_test.dart | 850 ++++++++++++++++++ pubspec.yaml | 2 + 14 files changed, 2325 insertions(+), 89 deletions(-) create mode 100644 pkg/dartdev/lib/src/commands/install.dart create mode 100644 pkg/dartdev/lib/src/commands/installed.dart create mode 100644 pkg/dartdev/lib/src/commands/uninstall.dart create mode 100644 pkg/dartdev/lib/src/install/file_system.dart create mode 100644 pkg/dartdev/lib/src/install/pub_formats.dart create mode 100644 pkg/dartdev/test/native_assets/install_test.dart diff --git a/pkg/dartdev/lib/dartdev.dart b/pkg/dartdev/lib/dartdev.dart index f3adcbbc798..fa6d370b251 100644 --- a/pkg/dartdev/lib/dartdev.dart +++ b/pkg/dartdev/lib/dartdev.dart @@ -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 { nativeAssetsExperimentEnabled: nativeAssetsExperimentEnabled, )); addCommand(ToolingDaemonCommand(verbose: verbose)); + if (nativeAssetsExperimentEnabled) { + addCommand(InstallCommand(verbose: verbose)); + addCommand(InstalledCommand(verbose: verbose)); + addCommand(UninstallCommand(verbose: verbose)); + } } @visibleForTesting diff --git a/pkg/dartdev/lib/src/commands/build.dart b/pkg/dartdev/lib/src/commands/build.dart index c2e895cf346..351be14d45f 100644 --- a/pkg/dartdev/lib/src/commands/build.dart +++ b/pkg/dartdev/lib/src/commands/build.dart @@ -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 doBuild({ + required DartBuildExecutables executables, + required Uri outputUri, + required Uri packageConfigUri, + required Uri? pubspecUri, + required bool recordUseEnabled, + required List 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})>; diff --git a/pkg/dartdev/lib/src/commands/install.dart b/pkg/dartdev/lib/src/commands/install.dart new file mode 100644 index 00000000000..b222a5a2670 --- /dev/null +++ b/pkg/dartdev/lib/src/commands/install.dart @@ -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 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 [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 .', + ); + + argParser.addOption( + 'git-ref', + help: 'Git branch or commit to be retrieved. ' + 'Only applies when using a git url for .', + ); + + 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 .', + ); + } + + /// 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 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 _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 _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 _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 _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 = []; + 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 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 _inTempDir( + Future 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; +} diff --git a/pkg/dartdev/lib/src/commands/installed.dart b/pkg/dartdev/lib/src/commands/installed.dart new file mode 100644 index 00000000000..7a08b962185 --- /dev/null +++ b/pkg/dartdev/lib/src/commands/installed.dart @@ -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 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 getInstalledPackages() { + final allAppBundles = DartInstallDirectory().allAppBundlesSync(); + final result = []; + 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; +} diff --git a/pkg/dartdev/lib/src/commands/uninstall.dart b/pkg/dartdev/lib/src/commands/uninstall.dart new file mode 100644 index 00000000000..fe8261576b3 --- /dev/null +++ b/pkg/dartdev/lib/src/commands/uninstall.dart @@ -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 and all executables from + placed on PATH.'''; + + @override + String get invocation { + final superNoArguments = super.invocation.replaceAll(' [arguments]', ''); + return '$superNoArguments '; + } + + @override + CommandCategory get commandCategory => CommandCategory.global; + + UninstallCommand({bool verbose = false}) + : super(cmdName, cmdDescription, verbose); + + @override + Future run() async { + final argResults = this.argResults!; + Iterable args = argResults.rest; + if (args.length != 1) { + final arguments = args.isEmpty ? 'none' : '"${args.join(' ')}"'; + usageException( + 'Wrong number of arguments, expected "", 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; + } +} diff --git a/pkg/dartdev/lib/src/core.dart b/pkg/dartdev/lib/src/core.dart index d9f40771d74..467a252ea2e 100644 --- a/pkg/dartdev/lib/src/core.dart +++ b/pkg/dartdev/lib/src/core.dart @@ -83,6 +83,7 @@ abstract class DartdevCommand extends Command { } enum CommandCategory { + global('Global'), project('Project'), sourceCode('Source code'), tools('Tools'); diff --git a/pkg/dartdev/lib/src/install/file_system.dart b/pkg/dartdev/lib/src/install/file_system.dart new file mode 100644 index 00000000000..14f1b6dca77 --- /dev/null +++ b/pkg/dartdev/lib/src/install/file_system.dart @@ -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. +/// +///
+/// [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/')
+/// 
+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 allAppBundlesSync({String? packageName}) { + final dartInstallAppbundlesDir = _appBundles; + if (!dartInstallAppbundlesDir.existsSync()) { + return []; + } + final packageDirs = + dartInstallAppbundlesDir.listSync().whereType(); + final result = []; + 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() + .map((d) => AppBundleDirectory._(d.ensureEndWithSeparator)), + ); + } + if (hostedDir.existsSync()) { + result.addAll( + hostedDir + .listSync() + .whereType() + .map((d) => AppBundleDirectory._(d.ensureEndWithSeparator)), + ); + } + if (localDir.existsSync()) { + result.add(AppBundleDirectory._(localDir.ensureEndWithSeparator)); + } + } + return result; + } +} + +/// The directory that contains all executables available on `PATH`. +/// +///
+/// [BinOnPathDirectory]
+/// └── [executable]s
+/// 
+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: +/// +///
+/// [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)
+/// 
+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 get executablesSync { + final binaries = _binDirectory + .listSync() + .whereType() + .map((e) => ExecutableInBundle._(e)) + .toList(); + return binaries; + } + + /// The executables from this bundle which are available on `PATH`. + List get executablesOnPathSync { + final result = []; + 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); +} diff --git a/pkg/dartdev/lib/src/install/pub_formats.dart b/pkg/dartdev/lib/src/install/pub_formats.dart new file mode 100644 index 00000000000..87516b457a5 --- /dev/null +++ b/pkg/dartdev/lib/src/install/pub_formats.dart @@ -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, + ); + } +} + +extension PackageConfigFile on PackageConfigFileSyntax { + static PackageConfigFileSyntax loadSync(File file) { + return PackageConfigFileSyntax.fromJson( + jsonDecode(file.readAsStringSync()) as Map, + ); + } +} + +final _jsonEncoder = JsonEncoder.withIndent(' '); + +Map _convertYamlMapToJsonMap(YamlMap yamlMap) { + final Map 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; + } +} diff --git a/pkg/dartdev/lib/src/utils.dart b/pkg/dartdev/lib/src/utils.dart index 6a83685ba40..c57bad337cd 100644 --- a/pkg/dartdev/lib/src/utils.dart +++ b/pkg/dartdev/lib/src/utils.dart @@ -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'; } diff --git a/pkg/dartdev/pubspec.yaml b/pkg/dartdev/pubspec.yaml index 7f67fdbaa36..2cd54112b72 100644 --- a/pkg/dartdev/pubspec.yaml +++ b/pkg/dartdev/pubspec.yaml @@ -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 diff --git a/pkg/dartdev/test/commands/help_test.dart b/pkg/dartdev/test/commands/help_test.dart index 884fbd783e2..89fb2daed14 100644 --- a/pkg/dartdev/test/commands/help_test.dart +++ b/pkg/dartdev/test/commands/help_test.dart @@ -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. diff --git a/pkg/dartdev/test/native_assets/helpers.dart b/pkg/dartdev/test/native_assets/helpers.dart index 43f27f7296e..dc25c7ced8c 100644 --- a/pkg/dartdev/test/native_assets/helpers.dart +++ b/pkg/dartdev/test/native_assets/helpers.dart @@ -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 inTempDir(Future 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 runDart({ Uri? workingDirectory, required Logger? logger, bool expectExitCodeZero = true, + Map? environment, }) async { final result = await runProcess( executable: dartExecutable, arguments: arguments, workingDirectory: workingDirectory, logger: logger, + environment: environment, ); if (expectExitCodeZero) { if (result.exitCode != 0) { diff --git a/pkg/dartdev/test/native_assets/install_test.dart b/pkg/dartdev/test/native_assets/install_test.dart new file mode 100644 index 00000000000..73f1a200c04 --- /dev/null +++ b/pkg/dartdev/test/native_assets/install_test.dart @@ -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 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 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 [version-constraint] +-h, --help Print this usage information. + --git-path Path of git package in repository. Only applies when using a git url for . + --git-ref Git branch or commit to be retrieved. Only applies when using a git url for . + --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 . + +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 and all executables from + placed on PATH. + +Usage: dart uninstall +-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 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 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 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> 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 args) async { + await Future.delayed(Duration(days: 1000000)); +} +'''); + Future 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 _runDartdev( + bool fromDartdevSource, + String command, + List arguments, + Uri? workingDirectory, + Map 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 _runToolForTest( + Map 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; +} diff --git a/pubspec.yaml b/pubspec.yaml index 4e2d4a7a47e..e7abde21e31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: