blob: b4a38b0af61535744db17b1df1ae2bd8aff5dab5 [file] [edit]
// Copyright (c) 2026, 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:args/command_runner.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
/// Command to download and extract a prebuilt DartPad SDK for Dart from
/// `gs://dart-archive`.
final class SetupDartCommand extends Command<void> {
@override
final String name = 'dart';
@override
final String description =
'Download and extract a DartPad SDK for Dart from gs://dart-archive.';
SetupDartCommand() {
argParser
..addOption(
'output',
abbr: 'o',
mandatory: true,
valueHelp: 'dir',
help: 'Output directory for the DartPad SDK.',
)
..addOption(
'channel',
abbr: 'c',
allowed: ['main', 'dev', 'beta', 'stable'],
defaultsTo: 'stable',
help: 'Dart release channel.',
)
..addOption(
'version',
defaultsTo: 'latest',
valueHelp: 'version',
help:
'Promoted release version (e.g. "3.13.0" or "latest"). '
'Ignored when --revision is provided; cannot be used with '
'--channel=main.',
)
..addOption(
'revision',
valueHelp: 'sha',
help:
'Specific 40-character Git commit SHA '
'(fetches from channels/<channel>/raw/hash/<sha>/dartpad/dartpad.zip).',
);
}
@override
Future<void> run() async {
final results = argResults!;
final outputDir = results.option('output')!;
final channel = results.option('channel')!;
final version = results.option('version')!;
final revision = results.option('revision');
final Uri url;
try {
url = resolveDartPadZipUrl(
channel: channel,
version: version,
revision: revision,
);
} on FormatException catch (e) {
usageException(e.message);
}
await downloadAndExtractDartPadZip(url, outputDir);
print('Successfully extracted DartPad SDK for Dart to $outputDir');
}
}
/// Resolves the `gs://dart-archive` HTTPS download URL for `dartpad.zip`.
Uri resolveDartPadZipUrl({
required String channel,
String version = 'latest',
String? revision,
}) {
const base = 'https://storage.googleapis.com/dart-archive/channels';
if (revision != null) {
final normalizedRev = revision.trim().toLowerCase();
if (!RegExp(r'^[0-9a-f]{40}$').hasMatch(normalizedRev)) {
throw FormatException(
'Expected a 40-character hex Git commit SHA for --revision, '
'got "$revision".',
);
}
return Uri.parse(
'$base/$channel/raw/hash/$normalizedRev/dartpad/dartpad.zip',
);
}
if (channel == 'main') {
if (version != 'latest') {
throw const FormatException(
'--version cannot be used with --channel=main '
'(use --revision=<sha> or omit --version).',
);
}
return Uri.parse('$base/main/raw/latest/dartpad/dartpad.zip');
}
return Uri.parse('$base/$channel/release/$version/dartpad/dartpad.zip');
}
/// Downloads `dartpad.zip` from [url] and extracts the contents of its
/// top-level `dartpad/` directory into [destDir] using the OS `unzip` utility.
Future<void> downloadAndExtractDartPadZip(Uri url, String destDir) async {
final outDir = Directory(destDir);
if (outDir.existsSync() && outDir.listSync().isNotEmpty) {
throw StateError(
'Output directory "$destDir" already exists and is not empty.',
);
}
outDir.createSync(recursive: true);
final extractor = await _resolveZipExtractor();
final tempDir = Directory.systemTemp.createTempSync('dartpad_setup_');
try {
final zipFile = File(p.join(tempDir.path, 'dartpad.zip'));
print('Downloading $url...');
final response = await http.get(url);
if (response.statusCode != 200) {
throw StateError(
'Failed to download $url (HTTP ${response.statusCode}).',
);
}
zipFile.writeAsBytesSync(response.bodyBytes);
final extractDir = Directory(p.join(tempDir.path, 'extracted'))
..createSync(recursive: true);
await extractZipFile(zipFile.path, extractDir.path, extractor: extractor);
final extractedDartPadDir = Directory(p.join(extractDir.path, 'dartpad'));
if (!extractedDartPadDir.existsSync()) {
throw StateError(
'Expected top-level "dartpad/" directory in archive from $url.',
);
}
copyDirectoryContents(extractedDartPadDir.path, outDir.path);
} finally {
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
}
}
/// Checks whether `unzip` (or `tar` on Windows) is available on `PATH`.
/// Returns `'unzip'` if `unzip` is available, or `'tar'` if falling back to
/// `tar` on Windows.
Future<String> _resolveZipExtractor() async {
final checkCmd = Platform.isWindows ? 'where' : 'which';
final unzipResult = await Process.run(checkCmd, ['unzip']);
if (unzipResult.exitCode == 0) return 'unzip';
if (Platform.isWindows) {
final tarResult = await Process.run('where', ['tar']);
if (tarResult.exitCode == 0) return 'tar';
}
final tools = Platform.isWindows ? '"unzip" or "tar"' : '"unzip"';
throw StateError(
'Could not find $tools on PATH. '
'Please install $tools and ensure it is available on your PATH.',
);
}
/// Extracts [zipPath] into [destDir] using OS `unzip` (or `tar -xf` on
/// Windows).
Future<void> extractZipFile(
String zipPath,
String destDir, {
String? extractor,
}) async {
final tool = extractor ?? await _resolveZipExtractor();
final ProcessResult result;
if (tool == 'unzip') {
result = await Process.run('unzip', ['-q', zipPath, '-d', destDir]);
} else {
result = await Process.run('tar', ['-xf', zipPath, '-C', destDir]);
}
if (result.exitCode != 0) {
throw StateError(
'Failed to extract $zipPath (exit code ${result.exitCode}):\n'
'${result.stdout}\n${result.stderr}',
);
}
}
/// Recursively copies all files in [source] into [dest].
void copyDirectoryContents(String source, String dest) {
final s = Directory(source);
if (!s.existsSync()) {
throw StateError('Expected directory $source to exist.');
}
Directory(dest).createSync(recursive: true);
for (final entity in s.listSync(recursive: true)) {
if (entity is File) {
final relative = p.relative(entity.path, from: source);
final destFile = File(p.join(dest, relative));
destFile.parent.createSync(recursive: true);
entity.copySync(destFile.path);
}
}
}