diff --git a/.vscode/cli.code-snippets b/.vscode/cli.code-snippets index ace2c1c..ebddd58 100644 --- a/.vscode/cli.code-snippets +++ b/.vscode/cli.code-snippets @@ -17,9 +17,10 @@ "import \"dart:async\";", "", "import \"package:args/command_runner.dart\";", + "import \"package:discloud/cli/disposable.dart\";", "import \"package:discloud/extensions/command.dart\";", "", - "class ${1:${TM_FILENAME_BASE/(^\\w)/${1:/upcase}/}}Command extends Command {", + "class ${1:${TM_FILENAME_BASE/(^\\w)/${1:/upcase}/}}Command extends Command with Disposable {", "\t${1:${TM_FILENAME_BASE/(^\\w)/${1:/upcase}/}}Command() {$4}", "", "\t@override", @@ -30,6 +31,9 @@ "", "\t@override", "\tFuture run() async {$0}", + "", + "\t@override", + "\tFuture dispose() async {$5}", "}", "" ] diff --git a/docs/commands.md b/docs/commands.md index 2f16843..5b3ee80 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -446,6 +446,15 @@ Usage: discloud user locale [arguments] -l, --locale= (mandatory) ``` +### wait + +```sh +Wait N seconds + +Usage: discloud wait [arguments] +-h, --help Print this usage information. +``` + ### zip ```sh diff --git a/lib/cli/disposable.dart b/lib/cli/disposable.dart index 995f050..754d850 100644 --- a/lib/cli/disposable.dart +++ b/lib/cli/disposable.dart @@ -1,3 +1,5 @@ -abstract class Disposable { - void dispose(); +import "dart:async"; + +abstract mixin class Disposable { + FutureOr dispose(); } diff --git a/lib/cli/runner.dart b/lib/cli/runner.dart index 86c6ac8..82602ed 100644 --- a/lib/cli/runner.dart +++ b/lib/cli/runner.dart @@ -1,8 +1,10 @@ import "dart:io"; +import "package:args/args.dart"; import "package:args/command_runner.dart"; import "package:discloud/commands/commands.dart"; import "package:discloud/commands/team.dart"; +import "package:discloud/commands/wait.dart"; import "package:discloud/version.dart"; class CliCommandRunner extends CommandRunner { @@ -31,6 +33,23 @@ class CliCommandRunner extends CommandRunner { addCommand(LoginCommand()); addCommand(TeamCommand()); addCommand(UserCommand()); + addCommand(WaitCommand()); addCommand(ZipCommand()); } + + Command? getCommand(ArgResults topLevelResults) { + var argResults = topLevelResults.command; + var commands = super.commands; + Command? command; + + while (argResults != null) { + command = commands[argResults.name]; + if (command == null) break; + + commands = command.subcommands; + argResults = argResults.command; + } + + return command; + } } diff --git a/lib/commands/app/backup.dart b/lib/commands/app/backup.dart index 161846e..7e2348c 100644 --- a/lib/commands/app/backup.dart +++ b/lib/commands/app/backup.dart @@ -2,8 +2,10 @@ import "dart:async"; import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/cli/spin/ispin.dart"; import "package:discloud/extensions/command.dart"; +import "package:discloud/extensions/file.dart"; import "package:discloud/utils/download.dart"; import "package:discloud/utils/messages.dart"; import "package:discloud/utils/progress.dart"; @@ -11,7 +13,7 @@ import "package:discloud/utils/speed_monitor.dart"; const _pSep = "/"; -class AppBackupCommand extends Command { +class AppBackupCommand extends Command with Disposable { AppBackupCommand() { argParser ..addOption("app", mandatory: true, valueHelp: "all") @@ -33,6 +35,10 @@ class AppBackupCommand extends Command { @override final aliases = const ["bkp"]; + HttpClient? _client; + File? _file; + SpeedMonitor? _monitor; + @override Future run() async { final appId = argResults!.option("app"); @@ -66,7 +72,7 @@ class AppBackupCommand extends Command { } Future _handleMulti(List list, ISpin spinner) async { - final client = HttpClient(); + final client = _client = .new(); final dir = argResults?.option("dir") ?? "."; for (final data in list) { @@ -84,8 +90,6 @@ class AppBackupCommand extends Command { await _download(dir: dir, spinner: spinner, uri: uri, client: client); } - - client.close(); } Future _download({ @@ -96,9 +100,9 @@ class AppBackupCommand extends Command { }) async { final filename = uri.pathSegments.last; final filepath = "$dir$_pSep$filename"; - final File file = .new(filepath); + final file = _file = .new(filepath); - final monitor = SpeedMonitor(); + final monitor = _monitor = .new(); try { spinner.start("Downloading..."); @@ -118,12 +122,20 @@ class AppBackupCommand extends Command { }, ); + // no delete on dispose + _file = null; + spinner.success(filepath); } catch (e, s) { spinner.fail(resolveResponseMessage(e)); context.printer.debug(s); - } finally { - monitor.dispose(); } } + + @override + Future dispose() async { + _client?.close(); + await _file?.safeDelete(); + _monitor?.dispose(); + } } diff --git a/lib/commands/app/commit.dart b/lib/commands/app/commit.dart index e16ccfe..5a81400 100644 --- a/lib/commands/app/commit.dart +++ b/lib/commands/app/commit.dart @@ -2,6 +2,7 @@ import "dart:async"; import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/extensions/command.dart"; import "package:discloud/extensions/file.dart"; import "package:discloud/services/discloud/constants.dart"; @@ -12,7 +13,7 @@ import "package:discloud/utils/zip.dart"; import "package:discloud_config/discloud_config.dart"; import "package:path/path.dart" hide context; -class AppCommitCommand extends Command { +class AppCommitCommand extends Command with Disposable { AppCommitCommand() { argParser ..addOption( @@ -31,6 +32,9 @@ class AppCommitCommand extends Command { @override final aliases = const ["c"]; + File? _file; + SpeedMonitor? _monitor; + @override Future run() async { final directory = context.workspaceFolder; @@ -46,7 +50,7 @@ class AppCommitCommand extends Command { final zipath = joinAll([directory.path, "${basename(directory.path)}.zip"]); - final File file = .new(zipath); + final file = _file = .new(zipath); await zip( directory: directory, @@ -61,33 +65,28 @@ class AppCommitCommand extends Command { final fileStat = await file.stat(); final total = fileStat.size; - final monitor = SpeedMonitor(); - - try { - spinner.start("Committing..."); - - final response = await context.api.putMultipart( - "/app/$appId/commit", - file: file, - onUploadProgress: (processed) { - spinner.text = formatProgressMessage( - speed: monitor.add(processed), - prefixText: "Committing:", - direction: .up, - processed: processed, - total: total, - ); - }, - onUploadDone: () { - spinner.start("Processing..."); - }, - ); - - spinner.success(resolveResponseMessage(response)); - } finally { - await file.safeDelete(); - monitor.dispose(); - } + final monitor = _monitor = .new(); + + spinner.start("Committing..."); + + final response = await context.api.putMultipart( + "/app/$appId/commit", + file: file, + onUploadProgress: (processed) { + spinner.text = formatProgressMessage( + speed: monitor.add(processed), + prefixText: "Committing:", + direction: .up, + processed: processed, + total: total, + ); + }, + onUploadDone: () { + spinner.start("Processing..."); + }, + ); + + spinner.success(resolveResponseMessage(response)); } Future _getDiscloudConfigAppId(Directory directory) async { @@ -97,4 +96,10 @@ class AppCommitCommand extends Command { return config.appId; } + + @override + Future dispose() async { + await _file?.safeDelete(); + _monitor?.dispose(); + } } diff --git a/lib/commands/app/upload.dart b/lib/commands/app/upload.dart index f4f7aae..a18f51c 100644 --- a/lib/commands/app/upload.dart +++ b/lib/commands/app/upload.dart @@ -1,6 +1,7 @@ import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/extensions/command.dart"; import "package:discloud/extensions/file.dart"; import "package:discloud/services/discloud/constants.dart"; @@ -11,7 +12,7 @@ import "package:discloud/utils/zip.dart"; import "package:discloud_config/discloud_config.dart"; import "package:path/path.dart" hide context; -class AppUploadCommand extends Command { +class AppUploadCommand extends Command with Disposable { AppUploadCommand() { argParser.addMultiOption("glob", abbr: "g", defaultsTo: const ["**"]); } @@ -25,6 +26,9 @@ class AppUploadCommand extends Command { @override final aliases = const ["up"]; + File? _file; + SpeedMonitor? _monitor; + @override Future run() async { final directory = context.workspaceFolder; @@ -41,7 +45,7 @@ class AppUploadCommand extends Command { final zipath = joinAll([directory.path, "${basename(directory.path)}.zip"]); - final File file = .new(zipath); + final file = _file = .new(zipath); await zip( directory: directory, @@ -56,32 +60,33 @@ class AppUploadCommand extends Command { final fileStat = await file.stat(); final total = fileStat.size; - final monitor = SpeedMonitor(); - - try { - spinner.start("Uploading..."); - - final response = await context.api.postMultipart( - "/upload", - file: file, - onUploadProgress: (processed) { - spinner.text = formatProgressMessage( - speed: monitor.add(processed), - prefixText: "Uploading:", - direction: .up, - processed: processed, - total: total, - ); - }, - onUploadDone: () { - spinner.start("Processing..."); - }, - ); - - spinner.success(resolveResponseMessage(response)); - } finally { - await file.safeDelete(); - monitor.dispose(); - } + final monitor = _monitor = .new(); + + spinner.start("Uploading..."); + + final response = await context.api.postMultipart( + "/upload", + file: file, + onUploadProgress: (processed) { + spinner.text = formatProgressMessage( + speed: monitor.add(processed), + prefixText: "Uploading:", + direction: .up, + processed: processed, + total: total, + ); + }, + onUploadDone: () { + spinner.start("Processing..."); + }, + ); + + spinner.success(resolveResponseMessage(response)); + } + + @override + Future dispose() async { + await _file?.safeDelete(); + _monitor?.dispose(); } } diff --git a/lib/commands/team/backup.dart b/lib/commands/team/backup.dart index 744a333..20f6e26 100644 --- a/lib/commands/team/backup.dart +++ b/lib/commands/team/backup.dart @@ -2,8 +2,10 @@ import "dart:async"; import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/cli/spin/ispin.dart"; import "package:discloud/extensions/command.dart"; +import "package:discloud/extensions/file.dart"; import "package:discloud/utils/download.dart"; import "package:discloud/utils/messages.dart"; import "package:discloud/utils/progress.dart"; @@ -11,7 +13,7 @@ import "package:discloud/utils/speed_monitor.dart"; const _pSep = "/"; -class TeamBackupCommand extends Command { +class TeamBackupCommand extends Command with Disposable { TeamBackupCommand() { argParser ..addOption("app", mandatory: true, valueHelp: "all") @@ -33,6 +35,10 @@ class TeamBackupCommand extends Command { @override final aliases = const ["bkp"]; + HttpClient? _client; + File? _file; + SpeedMonitor? _monitor; + @override Future run() async { final appId = argResults!.option("app"); @@ -66,7 +72,7 @@ class TeamBackupCommand extends Command { } Future _handleMulti(List list, ISpin spinner) async { - final client = HttpClient(); + final client = _client = .new(); final dir = argResults?.option("dir") ?? "."; for (final data in list) { @@ -84,8 +90,6 @@ class TeamBackupCommand extends Command { await _download(dir: dir, spinner: spinner, uri: uri, client: client); } - - client.close(); } Future _download({ @@ -96,9 +100,9 @@ class TeamBackupCommand extends Command { }) async { final filename = uri.pathSegments.last; final filepath = "$dir$_pSep$filename"; - final File file = .new(filepath); - final monitor = SpeedMonitor(); + final file = _file = .new(filepath); + final monitor = _monitor = .new(); try { spinner.start("Downloading..."); @@ -118,12 +122,20 @@ class TeamBackupCommand extends Command { }, ); + // no delete on dispose + _file = null; + spinner.success(filepath); } catch (e, s) { spinner.fail(resolveResponseMessage(e)); context.printer.debug(s); - } finally { - monitor.dispose(); } } + + @override + Future dispose() async { + _client?.close(); + await _file?.safeDelete(); + _monitor?.dispose(); + } } diff --git a/lib/commands/team/commit.dart b/lib/commands/team/commit.dart index 9b49b94..baabbd3 100644 --- a/lib/commands/team/commit.dart +++ b/lib/commands/team/commit.dart @@ -1,6 +1,8 @@ +import "dart:async"; import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/extensions/command.dart"; import "package:discloud/extensions/file.dart"; import "package:discloud/services/discloud/constants.dart"; @@ -11,7 +13,7 @@ import "package:discloud/utils/zip.dart"; import "package:discloud_config/discloud_config.dart"; import "package:path/path.dart" hide context; -class TeamCommitCommand extends Command { +class TeamCommitCommand extends Command with Disposable { TeamCommitCommand() { argParser ..addOption( @@ -30,6 +32,9 @@ class TeamCommitCommand extends Command { @override final aliases = const ["c"]; + File? _file; + SpeedMonitor? _monitor; + @override Future run() async { final directory = context.workspaceFolder; @@ -45,7 +50,7 @@ class TeamCommitCommand extends Command { final zipath = joinAll([directory.path, "${basename(directory.path)}.zip"]); - final File file = .new(zipath); + final file = _file = .new(zipath); await zip( directory: directory, @@ -60,33 +65,28 @@ class TeamCommitCommand extends Command { final fileStat = await file.stat(); final total = fileStat.size; - final monitor = SpeedMonitor(); - - try { - spinner.start("Committing..."); - - final response = await context.api.putMultipart( - "/team/$appId/commit", - file: file, - onUploadProgress: (processed) { - spinner.text = formatProgressMessage( - speed: monitor.add(processed), - prefixText: "Committing:", - direction: .up, - processed: processed, - total: total, - ); - }, - onUploadDone: () { - spinner.start("Processing..."); - }, - ); - - spinner.success(resolveResponseMessage(response)); - } finally { - await file.safeDelete(); - monitor.dispose(); - } + final monitor = _monitor = .new(); + + spinner.start("Committing..."); + + final response = await context.api.putMultipart( + "/team/$appId/commit", + file: file, + onUploadProgress: (processed) { + spinner.text = formatProgressMessage( + speed: monitor.add(processed), + prefixText: "Committing:", + direction: .up, + processed: processed, + total: total, + ); + }, + onUploadDone: () { + spinner.start("Processing..."); + }, + ); + + spinner.success(resolveResponseMessage(response)); } Future _getDiscloudConfigAppId(Directory directory) async { @@ -96,4 +96,10 @@ class TeamCommitCommand extends Command { return config.appId; } + + @override + FutureOr dispose() async { + await _file?.safeDelete(); + _monitor?.dispose(); + } } diff --git a/lib/commands/wait.dart b/lib/commands/wait.dart new file mode 100644 index 0000000..7e30a0f --- /dev/null +++ b/lib/commands/wait.dart @@ -0,0 +1,36 @@ +import "dart:async"; + +import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; +import "package:discloud/extensions/num.dart"; + +class WaitCommand extends Command with Disposable { + WaitCommand(); + + @override + final name = "wait"; + + @override + final description = "Wait N seconds"; + + @override + final hidden = true; + + @override + final takesArguments = true; + + @override + Future run() async { + if (argResults?.arguments.firstOrNull case final seconds?) { + if (int.tryParse(seconds) case final seconds? when seconds.isPositive) { + await Future.delayed(.new(seconds: seconds)); + } + } + } + + @override + FutureOr dispose() { + // ignore: no_runtimetype_tostring + print("$hashCode $runtimeType disposed"); + } +} diff --git a/lib/commands/zip.dart b/lib/commands/zip.dart index 2235d8d..6f08bcc 100644 --- a/lib/commands/zip.dart +++ b/lib/commands/zip.dart @@ -1,6 +1,8 @@ +import "dart:async"; import "dart:io"; import "package:args/command_runner.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/extensions/command.dart"; import "package:discloud/extensions/file.dart"; import "package:discloud/services/discloud/constants.dart"; @@ -8,7 +10,7 @@ import "package:discloud/utils/bytes.dart"; import "package:discloud/utils/zip.dart"; import "package:path/path.dart" hide context; -class ZipCommand extends Command { +class ZipCommand extends Command with Disposable { ZipCommand() { argParser ..addOption("encoding", abbr: "e", allowed: const ["buffer"], hide: true) @@ -34,6 +36,8 @@ class ZipCommand extends Command { return null; } + File? _file; + @override Future run() async { final directory = context.workspaceFolder; @@ -46,7 +50,7 @@ class ZipCommand extends Command { final spinner = context.printer.spin(text: "Zipping..."); - final File file = .new(out); + final file = _file = .new(out); await zip( directory: directory, @@ -65,8 +69,15 @@ class ZipCommand extends Command { switch (encoding) { case "buffer": await stdout.addStream(file.openRead()); - await file.safeDelete(); return; } + + // no delete on dispose + _file = null; + } + + @override + Future dispose() async { + await _file?.safeDelete(); } } diff --git a/lib/extensions/io_http_client.dart b/lib/extensions/io_http_client.dart index fa2f2c2..76aaaaa 100644 --- a/lib/extensions/io_http_client.dart +++ b/lib/extensions/io_http_client.dart @@ -1,13 +1,30 @@ import "dart:convert"; import "dart:io"; import "dart:isolate"; +import "dart:typed_data"; extension HttpClientResponseExtension on HttpClientResponse { static const _badRequestStatusCode = 400; bool get ok => statusCode < _badRequestStatusCode; - Future> get bodyBytes async => [await for (final e in this) ...e]; + Future get bodyBytes async { + if (contentLength case final length when length != -1) { + final bytes = Uint8List(length); + int index = 0; + await for (final e in this) { + bytes.setAll(index, e); + index += e.length; + } + return bytes; + } + + final bytes = []; + await for (final e in this) { + bytes.addAll(e); + } + return .fromList(bytes); + } Future get body => transform(utf8.decoder).join(); diff --git a/lib/main.dart b/lib/main.dart index 548c329..f0c344e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,11 @@ import "dart:io"; import "package:args/args.dart"; import "package:discloud/cli/context.dart"; +import "package:discloud/cli/disposable.dart"; import "package:discloud/cli/runner.dart"; import "package:discloud/extensions/arg_results.dart"; import "package:discloud/utils/messages.dart"; +import "package:discloud/utils/signal_wrapper.dart"; import "package:discloud/version.dart"; import "package:tint/tint.dart"; @@ -13,31 +15,52 @@ void main(Iterable arguments) async { final runner = CliCommandRunner(); - bool success = false; + ArgResults? argResults; try { - final argResults = runner.parse(arguments); - - _printCliHeader(argResults); - - await runner.runCommand(argResults); - - success = true; + argResults = runner.parse(arguments); } /* on FormatException */ catch (e, s) { context.printer ..error(resolveResponseMessage(e)) - ..debug(s); - } finally { - context.printer.debug("""\t -OS ${Platform.operatingSystemVersion} -Dart SDK v${Platform.version} -Discloud CLI v$packageVersion -"""); + ..debug(s) + ..debug(_version); + await context.dispose(); + exit(1); + } + + _printCliHeader(argResults); + final signal = SignalWrapper(.sigint); + try { + await signal.run( + () => runner.runCommand(argResults!), + dispose: () async { + if (runner.getCommand(argResults!) case final Disposable disposable) { + print("Disposing..."); + await disposable.dispose(); + } + }, + ); + } catch (e, s) { + context.printer + ..error(resolveResponseMessage(e)) + ..debug(s) + ..debug(_version); await context.dispose(); - exit(success ? 0 : 1); + exit(1); } + + context.printer.debug(_version); + await context.dispose(); + exit(signal.signed ? 2 : 0); } +String get _version => + """\t +OS ${Platform.operatingSystemVersion} +Dart SDK v${Platform.version} +Discloud CLI v$packageVersion +"""; + void _printCliHeader(ArgResults argResults) { final buffer = StringBuffer() ..writeAll(["discloud", ?argResults.commandName, "v$packageVersion"], " "); diff --git a/lib/utils/signal_wrapper.dart b/lib/utils/signal_wrapper.dart new file mode 100644 index 0000000..3bd0b6c --- /dev/null +++ b/lib/utils/signal_wrapper.dart @@ -0,0 +1,32 @@ +import "dart:async"; +import "dart:io"; + +class SignalWrapper { + SignalWrapper(this.signal) : _completer = .new(); + + final ProcessSignal signal; + final Completer _completer; + + bool get signed => _completer.isCompleted; + + Future run( + Future Function() fn, { + FutureOr Function()? dispose, + }) async { + final subscription = signal.watch().listen(_onData); + + try { + return await Future.any([_completer.future, fn()]); + } catch (_) { + rethrow; + } finally { + await dispose?.call(); + await subscription.cancel(); + } + } + + void _onData(ProcessSignal signal) { + if (signed) return; + _completer.complete(null); + } +} diff --git a/test/extensions/string_buffer_test.dart b/test/extensions/string_buffer_test.dart index c9a8954..d2b07ca 100644 --- a/test/extensions/string_buffer_test.dart +++ b/test/extensions/string_buffer_test.dart @@ -12,8 +12,8 @@ void main() { test("null", () { final buffer = StringBuffer() - ..writeAllCapitalized(const [null, null, null], separator); - expect(buffer.toString(), "Null null null"); + ..writeAllCapitalized(const [null, ?null, null], separator); + expect(buffer.toString(), "Null null"); }); test("text", () {