Allow publishing to and from a .tar.gz archive (#4119)

diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index a9aa9d2..f6ca1a4 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -3,7 +3,9 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
+import 'dart:typed_data';
 
 import 'package:http/http.dart' as http;
 
@@ -12,11 +14,12 @@
 import '../command.dart';
 import '../command_runner.dart';
 import '../exceptions.dart' show DataException;
-import '../exit_codes.dart' as exit_codes;
+import '../exit_codes.dart';
 import '../http.dart';
 import '../io.dart';
 import '../log.dart' as log;
 import '../oauth2.dart' as oauth2;
+import '../pubspec.dart';
 import '../solver/type.dart';
 import '../source/hosted.dart' show validateAndNormalizeHostedUrl;
 import '../utils.dart';
@@ -36,7 +39,7 @@
   bool get takesArguments => false;
 
   /// The URL of the server to which to upload the package.
-  late final Uri host = () {
+  Uri computeHost(Pubspec pubspec) {
     // An explicit argument takes precedence.
     if (argResults.wasParsed('server')) {
       try {
@@ -47,8 +50,8 @@
     }
 
     // Otherwise, use the one specified in the pubspec.
-    final publishTo = entrypoint.root.pubspec.publishTo;
-    if (publishTo != null) {
+    final publishTo = pubspec.publishTo;
+    if (publishTo != null && publishTo != 'none') {
       try {
         return validateAndNormalizeHostedUrl(publishTo);
       } on FormatException catch (e) {
@@ -58,7 +61,7 @@
 
     // Use the default server if nothing else is specified
     return Uri.parse(cache.hosted.defaultUrl);
-  }();
+  }
 
   /// Whether the publish is just a preview.
   bool get dryRun => argResults.flag('dry-run');
@@ -68,6 +71,10 @@
 
   bool get skipValidation => argResults.flag('skip-validation');
 
+  late final String? _fromArchive =
+      argResults.optionWithoutDefault('from-archive');
+  late final String? _toArchive = argResults.optionWithoutDefault('to-archive');
+
   LishCommand() {
     argParser.addFlag(
       'dry-run',
@@ -92,6 +99,19 @@
       help: 'The package server to which to upload this package.',
       hide: true,
     );
+    argParser.addOption(
+      'to-archive',
+      help: 'Create a .tar.gz archive instead of publishing to server',
+      valueHelp: '[archive.tar.gz]',
+      hide: true,
+    );
+    argParser.addOption(
+      'from-archive',
+      help:
+          'Publish from a .tar.gz archive instead of current folder. Implies `--skip-validation`.',
+      valueHelp: '[archive.tar.gz]',
+      hide: true,
+    );
 
     argParser.addOption(
       'directory',
@@ -104,6 +124,7 @@
   Future<void> _publishUsingClient(
     List<int> packageBytes,
     http.Client client,
+    Uri host,
   ) async {
     Uri? cloudStorageUrl;
 
@@ -189,7 +210,7 @@
     }
   }
 
-  Future<void> _publish(List<int> packageBytes) async {
+  Future<void> _publish(List<int> packageBytes, Uri host) async {
     try {
       final officialPubServers = {
         'https://pub.dev',
@@ -217,12 +238,12 @@
         // This allows us to use `dart pub token add` to inject a token for use
         // with the official servers.
         await oauth2.withClient((client) {
-          return _publishUsingClient(packageBytes, client);
+          return _publishUsingClient(packageBytes, client, host);
         });
       } else {
         // For third party servers using bearer authentication client
         await withAuthenticatedClient(cache, host, (client) {
-          return _publishUsingClient(packageBytes, client);
+          return _publishUsingClient(packageBytes, client, host);
         });
       }
     } on PubHttpResponseException catch (error) {
@@ -235,8 +256,7 @@
     }
   }
 
-  @override
-  Future runProtected() async {
+  Future<void> _validateArgs() async {
     if (argResults.wasParsed('server')) {
       await log.errorsOnlyUnlessTerminal(() {
         log.message(
@@ -251,18 +271,26 @@
       usageException('Cannot use both --force and --dry-run.');
     }
 
-    if (entrypoint.root.pubspec.isPrivate) {
-      dataError('A private package cannot be published.\n'
-          'You can enable this by changing the "publish_to" field in your '
-          'pubspec.');
+    if (_fromArchive != null && _toArchive != null) {
+      usageException('Cannot use both --from-archive and --to-archive.');
     }
 
-    if (!skipValidation) {
-      await entrypoint.acquireDependencies(SolveType.get);
-    } else {
+    if (_fromArchive != null && dryRun) {
+      usageException('Cannot use both --from-archive and --dry-run.');
+    }
+
+    if (_toArchive != null && force) {
+      usageException('Cannot use both --to-archive and --force.');
+    }
+  }
+
+  Future<_Publication> _publicationFromEntrypoint() async {
+    if (skipValidation) {
       log.warning(
         'Running with `skip-validation`. No client-side validation is done.',
       );
+    } else {
+      await entrypoint.acquireDependencies(SolveType.get);
     }
 
     var files = entrypoint.root.listFiles();
@@ -270,45 +298,79 @@
 
     // Show the package contents so the user can verify they look OK.
     var package = entrypoint.root;
+    final host = computeHost(package.pubspec);
     log.message(
       'Publishing ${package.name} ${package.version} to $host:\n'
       '${tree.fromFiles(files, baseDir: entrypoint.rootDir, showFileSizes: true)}',
     );
 
-    var packageBytes =
+    final packageBytes =
         await createTarGz(files, baseDir: entrypoint.rootDir).toBytes();
+
     log.message(
       '\nTotal compressed archive size: ${_readableFileSize(packageBytes.length)}.\n',
     );
 
-    // Validate the package.
-    var isValid = skipValidation
-        ? true
-        : await _validate(
-            packageBytes.length,
-            files,
-          );
-    if (!isValid) {
-      overrideExitCode(exit_codes.DATA);
-      return;
-    } else if (dryRun) {
+    final validationResult =
+        skipValidation ? null : await _validate(packageBytes, files, host);
+
+    if (dryRun) {
       log.message('The server may enforce additional checks.');
-      return;
-    } else {
-      await _publish(packageBytes);
     }
+    return _Publication(
+      packageBytes: packageBytes,
+      warningCount: validationResult?.warningsCount ?? 0,
+      hintCount: validationResult?.hintsCount ?? 0,
+      pubspec: package.pubspec,
+    );
   }
 
-  /// Returns the value associated with [key] in [map]. Throws a user-friendly
-  /// error if [map] doesn't contain [key].
-  dynamic _expectField(Map map, String key, http.Response response) {
-    if (map.containsKey(key)) return map[key];
-    invalidServerResponse(response);
+  Future<_Publication> _publicationFromArchive(String archive) async {
+    final Uint8List packageBytes;
+    try {
+      log.message('Publishing from archive: $_fromArchive');
+
+      packageBytes = readBinaryFile(archive);
+    } on FileSystemException catch (e) {
+      dataError(
+        'Failed reading archive file: $e)',
+      );
+    }
+    final Pubspec pubspec;
+    try {
+      pubspec = Pubspec.parse(
+        utf8.decode(
+          await extractFileFromTarGz(
+            Stream.fromIterable([packageBytes]),
+            'pubspec.yaml',
+          ),
+        ),
+        cache.sources,
+      );
+    } on FormatException catch (e) {
+      dataError('Failed to read pubspec.yaml from archive: ${e.message}');
+    }
+    final host = computeHost(pubspec);
+    log.message('Publishing ${pubspec.name} ${pubspec.version} to $host.');
+    return _Publication(
+      packageBytes: packageBytes,
+      warningCount: 0,
+      hintCount: 0,
+      pubspec: pubspec,
+    );
   }
 
-  /// Validates the package. Completes to false if the upload should not
+  /// Validates the package.
+  ///
+  /// Throws if there are errors and the upload should not
   /// proceed.
-  Future<bool> _validate(int packageSize, List<String> files) async {
+  ///
+  /// Returns a summary of warnings and hints if there are any, otherwise `null`.
+  Future<({int warningsCount, int hintsCount})> _validate(
+    Uint8List packageBytes,
+    List<String> files,
+    Uri host,
+  ) async {
     final hints = <String>[];
     final warnings = <String>[];
     final errors = <String>[];
@@ -317,7 +379,7 @@
       'Validating package',
       () async => await Validator.runAll(
         entrypoint,
-        packageSize,
+        packageBytes.length,
         host,
         files,
         hints: hints,
@@ -327,46 +389,80 @@
     );
 
     if (errors.isNotEmpty) {
-      log.error('Sorry, your package is missing '
+      dataError('Sorry, your package is missing '
           "${(errors.length > 1) ? 'some requirements' : 'a requirement'} "
           "and can't be published yet.\nFor more information, see: "
           'https://dart.dev/tools/pub/cmd/pub-lish.\n');
-      return false;
     }
 
-    if (force) return true;
+    return (warningsCount: warnings.length, hintsCount: hints.length);
+  }
 
-    String formatWarningCount() {
-      final hintText = hints.isEmpty
-          ? ''
-          : ' and ${hints.length} ${pluralize('hint', hints.length)}';
-      return '\nPackage has ${warnings.length} '
-          '${pluralize('warning', warnings.length)}$hintText.';
-    }
-
-    if (dryRun) {
-      log.warning(formatWarningCount());
-      return warnings.isEmpty;
-    }
-
+  /// Asks the user for confirmation of uploading [package].
+  ///
+  /// Skips asking if [force].
+  /// Throws if user didn't confirm.
+  Future<void> _confirmUpload(_Publication package, Uri host) async {
+    if (force) return;
     log.message('\nPublishing is forever; packages cannot be unpublished.'
-        '\nPolicy details are available at https://pub.dev/policy');
+        '\nPolicy details are available at https://pub.dev/policy\n');
 
-    final package = entrypoint.root;
     var message =
-        'Do you want to publish ${package.name} ${package.version} to $host';
-
-    if (warnings.isNotEmpty || hints.isNotEmpty) {
-      final warning = formatWarningCount();
-      message = '${log.bold(log.red(warning))}. $message';
+        'Do you want to publish ${package.pubspec.name} ${package.pubspec.version} to $host';
+    if (package.hintCount != 0 || package.warningCount != 0) {
+      message = '${package.warningsCountMessage}. $message';
     }
-
-    var confirmed = await confirm('\n$message');
-    if (!confirmed) {
-      log.error('Package upload canceled.');
-      return false;
+    if (!await confirm('\n$message')) {
+      dataError('Package upload canceled.');
     }
-    return true;
+  }
+
+  @override
+  Future runProtected() async {
+    await _validateArgs();
+    final publication = await (_fromArchive == null
+        ? _publicationFromEntrypoint()
+        : _publicationFromArchive(_fromArchive));
+    if (dryRun) {
+      log.warning(publication.warningsCountMessage);
+      if (publication.warningCount != 0) {
+        overrideExitCode(DATA);
+      }
+      return;
+    }
+    if (_toArchive == null) {
+      final host = computeHost(publication.pubspec);
+      if (publication.pubspec.isPrivate) {
+        dataError('A private package cannot be published.\n'
+            'You can enable this by changing the "publish_to" field in your '
+            'pubspec.');
+      }
+      await _confirmUpload(publication, host);
+
+      await _publish(publication.packageBytes, host);
+    } else {
+      if (dryRun) {
+        log.message('Would have written to $_toArchive.');
+      } else {
+        _writeUploadToArchive(publication, _toArchive);
+      }
+    }
+  }
+
+  void _writeUploadToArchive(_Publication publication, String archive) {
+    try {
+      writeBinaryFile(archive, publication.packageBytes);
+    } on FileSystemException catch (e) {
+      dataError('Failed writing archive: $e');
+    }
+    log.message('Wrote package archive at $_toArchive');
+  }
+
+  /// Returns the value associated with [key] in [map]. Throws a user-friendly
+  /// error if [map] doesn't contain [key].
+  dynamic _expectField(Map map, String key, http.Response response) {
+    if (map.containsKey(key)) return map[key];
+    invalidServerResponse(response);
   }
 }
 
@@ -381,3 +477,25 @@
     return '<1 KB';
   }
 }
+
+class _Publication {
+  Uint8List packageBytes;
+  int warningCount;
+  int hintCount;
+
+  Pubspec pubspec;
+
+  String get warningsCountMessage {
+    final hintText =
+        hintCount == 0 ? '' : ' and $hintCount ${pluralize('hint', hintCount)}';
+    return '\nPackage has $warningCount '
+        '${pluralize('warning', warningCount)}$hintText.';
+  }
+
+  _Publication({
+    required this.packageBytes,
+    required this.warningCount,
+    required this.hintCount,
+    required this.pubspec,
+  });
+}
diff --git a/lib/src/io.dart b/lib/src/io.dart
index 3d105a1..79ebaba 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -9,7 +9,9 @@
 import 'dart:collection';
 import 'dart:convert';
 import 'dart:io';
+import 'dart:typed_data';
 
+import 'package:async/async.dart';
 import 'package:cli_util/cli_util.dart'
     show EnvironmentNotFoundException, applicationConfigHome;
 import 'package:http/http.dart' show ByteStream;
@@ -205,7 +207,7 @@
 }
 
 /// Reads the contents of the binary file [file].
-List<int> readBinaryFile(String file) {
+Uint8List readBinaryFile(String file) {
   log.io('Reading binary file $file.');
   var contents = File(file).readAsBytesSync();
   log.io('Read ${contents.length} bytes from $file.');
@@ -239,6 +241,12 @@
   File(file).writeAsStringSync(contents, encoding: encoding);
 }
 
+/// Reads the contents of the binary file [file].
+void writeBinaryFile(String file, Uint8List data) {
+  log.io('Writing ${data.length} bytes to file $file.');
+  File(file).writeAsBytesSync(data);
+}
+
 /// Creates [file] and writes [contents] to it.
 ///
 /// If [dontLogContents] is `true`, the contents of the file will never be
@@ -666,9 +674,9 @@
 /// should just be a fragment like, "Are you sure you want to proceed". The
 /// default for an empty response, or any response not starting with `y` or `Y`
 /// is false.
-Future<bool> confirm(String message) {
-  log.fine('Showing confirm message: $message');
-  return stdinPrompt('$message (y/N)?').then(RegExp(r'^[yY]').hasMatch);
+Future<bool> confirm(String message) async {
+  final reply = await stdinPrompt('$message (y/N)?');
+  return RegExp(r'^[yY]').hasMatch(reply);
 }
 
 /// Writes [prompt] and reads a line from stdin.
@@ -1000,8 +1008,32 @@
   return server;
 }
 
+/// Extracts a single file from a `.tar.gz` [stream].
+///
+/// [filename] should be the relative path inside the archive (with unix
+/// separators '/').
+///
+/// Throws a `FormatException` if that file did not exist.
+Future<Uint8List> extractFileFromTarGz(
+  Stream<List<int>> stream,
+  String filename,
+) async {
+  final reader = TarReader(stream.transform(gzip.decoder));
+  filename = path.posix.normalize(filename);
+  while (await reader.moveNext()) {
+    final entry = reader.current;
+    if (path.posix.normalize(entry.name) != filename) continue;
+    if (!(entry.type == TypeFlag.reg || entry.type == TypeFlag.regA)) {
+      // Can only read regular files.
+      throw FormatException('$filename is not a file');
+    }
+    return await collectBytes(entry.contents);
+  }
+  throw FormatException('Could not find $filename in archive');
+}
+
 /// Extracts a `.tar.gz` file from [stream] to [destination].
-Future extractTarGz(Stream<List<int>> stream, String destination) async {
+Future<void> extractTarGz(Stream<List<int>> stream, String destination) async {
   log.fine('Extracting .tar.gz stream to $destination.');
 
   destination = path.absolute(destination);
diff --git a/test/descriptor.dart b/test/descriptor.dart
index 8fb6531..a91cc64 100644
--- a/test/descriptor.dart
+++ b/test/descriptor.dart
@@ -37,8 +37,12 @@
     libPubspec('test_pkg', '1.0.0', sdk: '>=3.1.2 <=3.2.0', extras: extras);
 
 /// Describes a package that passes all validation.
-DirectoryDescriptor validPackage({String version = '1.0.0'}) => dir(appPath, [
-      validPubspec(extras: {'version': version}),
+DirectoryDescriptor validPackage({
+  String version = '1.0.0',
+  Map<String, Object?>? pubspecExtras,
+}) =>
+    dir(appPath, [
+      validPubspec(extras: {'version': version, ...?pubspecExtras}),
       file('LICENSE', 'Eh, do what you want.'),
       file('README.md', "This package isn't real."),
       file('CHANGELOG.md', '# $version\nFirst version\n'),
diff --git a/test/lish/does_not_publish_if_private_test.dart b/test/lish/does_not_publish_if_private_test.dart
index c47610b..47f608d 100644
--- a/test/lish/does_not_publish_if_private_test.dart
+++ b/test/lish/does_not_publish_if_private_test.dart
@@ -10,13 +10,11 @@
 
 void main() {
   test('does not publish if the package is private', () async {
-    var pkg = packageMap('test_pkg', '1.0.0');
-    pkg['publish_to'] = 'none';
-    await d.dir(appPath, [d.pubspec(pkg)]).create();
+    await d.validPackage(pubspecExtras: {'publish_to': 'none'}).create();
 
     await runPub(
       args: ['lish'],
-      error: startsWith('A private package cannot be published.'),
+      error: contains('A private package cannot be published.'),
       exitCode: exit_codes.DATA,
     );
   });
diff --git a/test/lish/does_not_publish_if_private_with_server_arg_test.dart b/test/lish/does_not_publish_if_private_with_server_arg_test.dart
index 689609b..c4fe7b9 100644
--- a/test/lish/does_not_publish_if_private_with_server_arg_test.dart
+++ b/test/lish/does_not_publish_if_private_with_server_arg_test.dart
@@ -12,13 +12,11 @@
   test(
       'does not publish if the package is private even if a server '
       'argument is provided', () async {
-    var pkg = packageMap('test_pkg', '1.0.0');
-    pkg['publish_to'] = 'none';
-    await d.dir(appPath, [d.pubspec(pkg)]).create();
+    await d.validPackage(pubspecExtras: {'publish_to': 'none'}).create();
 
     await runPub(
       args: ['lish'],
-      error: startsWith('A private package cannot be published.'),
+      error: contains('A private package cannot be published.'),
       environment: {'PUB_HOSTED_URL': 'http://example.com'},
       exitCode: exit_codes.DATA,
     );
diff --git a/test/lish/dry_run_errors_if_private_test.dart b/test/lish/dry_run_errors_if_private_test.dart
deleted file mode 100644
index c9b6c6a..0000000
--- a/test/lish/dry_run_errors_if_private_test.dart
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-import 'package:pub/src/exit_codes.dart' as exit_codes;
-
-import 'package:test/test.dart';
-
-import '../descriptor.dart' as d;
-import '../test_pub.dart';
-
-void main() {
-  test('dry-run shows an error if the package is private', () async {
-    var pkg = packageMap('test_pkg', '1.0.0');
-    pkg['publish_to'] = 'none';
-    await d.dir(appPath, [d.pubspec(pkg)]).create();
-
-    await runPub(
-      args: ['lish', '--dry-run'],
-      error: startsWith('A private package cannot be published.'),
-      exitCode: exit_codes.DATA,
-    );
-  });
-}
diff --git a/test/lish/force_does_not_publish_if_private_test.dart b/test/lish/force_does_not_publish_if_private_test.dart
index edd3611..61dfb21 100644
--- a/test/lish/force_does_not_publish_if_private_test.dart
+++ b/test/lish/force_does_not_publish_if_private_test.dart
@@ -10,13 +10,11 @@
 
 void main() {
   test('force does not publish if the package is private', () async {
-    var pkg = packageMap('test_pkg', '1.0.0');
-    pkg['publish_to'] = 'none';
-    await d.dir(appPath, [d.pubspec(pkg)]).create();
+    await d.validPackage(pubspecExtras: {'publish_to': 'none'}).create();
 
     await runPub(
       args: ['lish', '--force'],
-      error: startsWith('A private package cannot be published.'),
+      error: contains('A private package cannot be published.'),
       exitCode: exit_codes.DATA,
     );
   });
diff --git a/test/lish/publishing_to_and_from_archive_test.dart b/test/lish/publishing_to_and_from_archive_test.dart
new file mode 100644
index 0000000..8d3ee5c
--- /dev/null
+++ b/test/lish/publishing_to_and_from_archive_test.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2024, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:pub/src/exit_codes.dart';
+import 'package:shelf/shelf.dart';
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+import 'utils.dart';
+
+void main() {
+  test('Can publish into and from archive', () async {
+    final server = await servePackages();
+    await d.validPackage().create();
+    await d.credentialsFile(server, 'access-token').create();
+    await runPub(
+      args: ['lish', '--to-archive', p.join('..', 'archive.tar.gz')],
+      output: contains(
+        'Wrote package archive at ${p.join('..', 'archive.tar.gz')}',
+      ),
+    );
+    expect(File(d.path('archive.tar.gz')).existsSync(), isTrue);
+
+    server.expect('GET', '/create', (request) {
+      return Response.ok(
+        jsonEncode({
+          'success': {'message': 'Package test_pkg 1.0.0 uploaded!'},
+        }),
+      );
+    });
+
+    final pub = await startPublish(
+      server,
+      args: ['--from-archive', 'archive.tar.gz'],
+      // Run outside the appPath to make sure we are not publishing that dir.
+      workingDirectory: d.sandbox,
+    );
+
+    expect(pub.stdout, emitsThrough('Publishing from archive: archive.tar.gz'));
+    await confirmPublish(pub);
+
+    handleUploadForm(server);
+    handleUpload(server);
+
+    expect(pub.stdout, emitsThrough(startsWith('Uploading...')));
+    expect(pub.stdout, emits('Package test_pkg 1.0.0 uploaded!'));
+    await pub.shouldExit(SUCCESS);
+  });
+}
diff --git a/test/lish/server_arg_does_not_override_private_test.dart b/test/lish/server_arg_does_not_override_private_test.dart
index f9d7bf8..e6cb779 100644
--- a/test/lish/server_arg_does_not_override_private_test.dart
+++ b/test/lish/server_arg_does_not_override_private_test.dart
@@ -11,13 +11,11 @@
 
 void main() {
   test('an explicit --server argument does not override privacy', () async {
-    var pkg = packageMap('test_pkg', '1.0.0');
-    pkg['publish_to'] = 'none';
-    await d.dir(appPath, [d.pubspec(pkg)]).create();
+    await d.validPackage(pubspecExtras: {'publish_to': 'none'}).create();
 
     await runPub(
       args: ['lish', '--server', 'http://arg.com'],
-      error: startsWith('A private package cannot be published.'),
+      error: contains('A private package cannot be published.'),
       exitCode: exit_codes.DATA,
     );
   });
diff --git a/test/lish/skip_validation_test.dart b/test/lish/skip_validation_test.dart
index fa2e995..e9b3dc0 100644
--- a/test/lish/skip_validation_test.dart
+++ b/test/lish/skip_validation_test.dart
@@ -35,6 +35,8 @@
     await servePackages();
     var pub = await startPublish(globalServer, args: ['--skip-validation']);
 
+    await confirmPublish(pub);
+
     handleUploadForm(globalServer);
     handleUpload(globalServer);
 
diff --git a/test/test_pub.dart b/test/test_pub.dart
index cad7f30..7aa3cf6 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -402,6 +402,7 @@
   bool overrideDefaultHostedServer = true,
   Map<String, String>? environment,
   String path = '',
+  String? workingDirectory,
 }) async {
   var tokenEndpoint = Uri.parse(server.url).resolve('/token').toString();
   args = ['lish', ...?args];
@@ -415,6 +416,7 @@
         'PUB_HOSTED_URL': server.url + path,
       if (environment != null) ...environment,
     },
+    workingDirectory: workingDirectory,
   );
 }