diff --git a/lib/commands/app/apt/install.dart b/lib/commands/app/apt/install.dart index 6a5f7d0..57b18d5 100644 --- a/lib/commands/app/apt/install.dart +++ b/lib/commands/app/apt/install.dart @@ -8,20 +8,23 @@ import "package:discloud/utils/messages.dart"; final class AppAptInstallCommand extends Command { AppAptInstallCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addMultiOption("apt", help: appApts.join(",")); } @override final name = "install"; + @override + final aliases = const ["i"]; + @override final description = "Install APT on your app"; @override Future run() async { - final appId = argResults!.option("app"); - final apts = argResults!.multiOption("apt"); + final appId = argResults!.requiredOptionOrRest("app"); + final apts = argResults!.requiredMultiOptionOrRest("apt", ["app"]); if (apts.isEmpty) usageException("Apt option cannot be empty"); diff --git a/lib/commands/app/apt/uninstall.dart b/lib/commands/app/apt/uninstall.dart index 85f3fbe..8084089 100644 --- a/lib/commands/app/apt/uninstall.dart +++ b/lib/commands/app/apt/uninstall.dart @@ -8,20 +8,23 @@ import "package:discloud/utils/messages.dart"; final class AppAptUninstallCommand extends Command { AppAptUninstallCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addMultiOption("apt", help: appApts.join(",")); } @override final name = "uninstall"; + @override + final aliases = const ["u"]; + @override final description = "Uninstall APT from your app"; @override Future run() async { - final appId = argResults!.option("app"); - final apts = argResults!.multiOption("apt"); + final appId = argResults!.requiredOptionOrRest("app"); + final apts = argResults!.requiredMultiOptionOrRest("apt", ["app"]); if (apts.isEmpty) usageException("Apt option cannot be empty"); diff --git a/lib/commands/app/backup.dart b/lib/commands/app/backup.dart index ca2d9aa..15e9a58 100644 --- a/lib/commands/app/backup.dart +++ b/lib/commands/app/backup.dart @@ -10,17 +10,16 @@ import "package:discloud/utils/download.dart"; import "package:discloud/utils/messages.dart"; import "package:discloud/utils/progress.dart"; import "package:discloud/utils/speed_monitor.dart"; - -const _pSep = "/"; +import "package:path/path.dart" hide context; final class AppBackupCommand extends Command with Disposable { AppBackupCommand() { argParser - ..addOption("app", mandatory: true, valueHelp: "all") + ..addOption("app", valueHelp: "all") ..addOption( "dir", abbr: "d", - aliases: const ["out"], + aliases: const ["out", "path"], help: "Specifies the destination path for downloading backups. The destination path will be considered a directory.", ); @@ -41,7 +40,7 @@ final class AppBackupCommand extends Command with Disposable { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final spinner = context.printer.spin(text: "Fetching backup..."); @@ -61,7 +60,7 @@ final class AppBackupCommand extends Command with Disposable { Future _handleSingle(Map data, ISpin spinner) async { if (data["url"] case final String url) { - if (argResults?.option("dir") case final dir?) { + if (argResults!.optionOrRest("dir", ["app"]) case final dir) { final Uri uri = .parse(url); return _download(dir: dir, spinner: spinner, uri: uri); @@ -73,7 +72,7 @@ final class AppBackupCommand extends Command with Disposable { Future _handleMulti(List list, ISpin spinner) async { final client = _client = .new(); - final dir = argResults?.option("dir") ?? "."; + final dir = argResults?.optionOrRest("dir") ?? "."; for (final data in list) { final String appId = data["id"]; @@ -99,7 +98,7 @@ final class AppBackupCommand extends Command with Disposable { HttpClient? client, }) async { final filename = uri.pathSegments.last; - final filepath = "$dir$_pSep$filename"; + final filepath = joinAll([dir, filename]); final file = _file = .new(filepath); final monitor = _monitor = .new(); diff --git a/lib/commands/app/commit.dart b/lib/commands/app/commit.dart index 14cf318..4de7024 100644 --- a/lib/commands/app/commit.dart +++ b/lib/commands/app/commit.dart @@ -39,12 +39,15 @@ final class AppCommitCommand extends Command with Disposable { Future run() async { final directory = context.workspaceFolder; + final optionAppId = argResults!.option("app"); final appId = - argResults!.option("app") ?? await _getDiscloudConfigAppId(directory); + optionAppId ?? + argResults?.rest.firstOrNull ?? + await _getDiscloudConfigAppId(directory); if (appId == null) throw Exception("Missing app id"); - final glob = argResults!.multiOption("glob"); + final glob = argResults?.multiOptionOrRest("glob", [if (optionAppId != null) "app"]) ?? const ["**"]; final spinner = context.printer.spin(text: "Zipping..."); @@ -82,7 +85,6 @@ final class AppCommitCommand extends Command with Disposable { ); }, onUploadDone: () { - _file = null; spinner.start("Processing..."); }, ); diff --git a/lib/commands/app/console.dart b/lib/commands/app/console.dart index 0d9058e..acb78be 100644 --- a/lib/commands/app/console.dart +++ b/lib/commands/app/console.dart @@ -11,7 +11,7 @@ import "package:tint/tint.dart"; final class AppConsoleCommand extends Command { AppConsoleCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addOption("command"); } @@ -26,7 +26,7 @@ final class AppConsoleCommand extends Command { @override Future run() async { - final appId = argResults!.option("app")!; + final appId = argResults!.requiredOptionOrRest("app"); String? command = argResults!.option("command"); final spinner = context.printer.spin(start: false); diff --git a/lib/commands/app/delete.dart b/lib/commands/app/delete.dart index bc86e52..5c6ec44 100644 --- a/lib/commands/app/delete.dart +++ b/lib/commands/app/delete.dart @@ -14,7 +14,7 @@ final class AppDeleteCommand extends Command { help: "Skip confirmation prompt", negatable: false, ) - ..addOption("app", mandatory: true); + ..addOption("app"); } @override @@ -25,7 +25,7 @@ final class AppDeleteCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final result = argResults!.flag("yes") || diff --git a/lib/commands/app/info.dart b/lib/commands/app/info.dart index 787fd8d..699de24 100644 --- a/lib/commands/app/info.dart +++ b/lib/commands/app/info.dart @@ -22,7 +22,7 @@ const _keysIgnore = { final class AppInfoCommand extends Command { AppInfoCommand() { - argParser.addOption("app", defaultsTo: "all"); + argParser.addOption("app", valueHelp: "all"); } @override @@ -33,7 +33,7 @@ final class AppInfoCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults?.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Fetching app info..."); diff --git a/lib/commands/app/logs.dart b/lib/commands/app/logs.dart index 1882cab..24b6c62 100644 --- a/lib/commands/app/logs.dart +++ b/lib/commands/app/logs.dart @@ -13,13 +13,13 @@ final class AppLogsCommand extends Command { argParser ..addOption( "app", - mandatory: true, valueHelp: "all", help: "When set to 'all', this command will automatically download logs and will not display URLs. If the 'out' option is not set, downloads will be made to the current folder.", ) ..addOption( "out", + aliases: const ["path"], help: "Specifies the destination path for downloading logs. When the application option is set to 'all', the destination path will be considered a directory where all downloads will be stored.", ); @@ -33,7 +33,8 @@ final class AppLogsCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; + final out = argResults!.optionOrRest("out", ["app"]); final spinner = context.printer.spin(); @@ -43,40 +44,44 @@ final class AppLogsCommand extends Command { switch (response["apps"]) { case final Map data: - await _handleSingle(data, argResults?.option("out"), spinner); + await _handleSingle(data, spinner, appId, out); break; case final List list: - await _handleMulti(list, spinner); + await _handleMulti(list, spinner, out ?? "."); break; } } Future _handleSingle( Map data, - String? out, ISpin spinner, + String appId, + String? out, ) async { - if (data["terminal"]?["big"] case final String data) { - if (out case final out?) return _saveLog(data, out, spinner); - context.printer.writeln(data); + if (data["terminal"]?["big"] case final String contents) { + if (out case final out?) { + return _saveLog(contents, _resolvePath(out, appId), spinner); + } + context.printer.writeln(contents); } } - Future _handleMulti(List list, ISpin spinner) async { - final out = argResults?.option("out") ?? "."; - + Future _handleMulti(List list, ISpin spinner, String out) async { for (final data in list) { final String appId = data["id"]; - final filename = "$appId.log"; - final filepath = joinAll([out, filename]); - await _handleSingle(data, filepath, spinner); + await _handleSingle(data, spinner, appId, out); } } Future _saveLog(String data, String out, ISpin spinner) async { - final file = File(out); - await file.create(recursive: true); + final File file = .new(out); + await file.parent.create(recursive: true); await file.writeAsString(stripAnsi(data)); spinner.success(out); } + + String _resolvePath(String out, String appId) { + if (extension(out).isNotEmpty) return out; + return joinAll([out, "$appId.log"]); + } } diff --git a/lib/commands/app/mod/add.dart b/lib/commands/app/mod/add.dart index 80be8e0..cf8b4ee 100644 --- a/lib/commands/app/mod/add.dart +++ b/lib/commands/app/mod/add.dart @@ -8,8 +8,8 @@ import "package:discloud/utils/messages.dart"; final class AppModAddCommand extends Command { AppModAddCommand() { argParser - ..addOption("app", mandatory: true) - ..addOption("mod", mandatory: true) + ..addOption("app") + ..addOption("mod") ..addMultiOption("perms", allowed: appModPerms); } @@ -21,8 +21,8 @@ final class AppModAddCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); - final modId = argResults!.option("mod"); + final appId = argResults!.requiredOptionOrRest("app"); + final modId = argResults!.requiredOptionOrRest("mod", ["app"]); final perms = argResults!.multiOption("perms"); final spinner = context.printer.spin(text: "Adding app MOD..."); diff --git a/lib/commands/app/mod/delete.dart b/lib/commands/app/mod/delete.dart index 34b59c5..fcf9b84 100644 --- a/lib/commands/app/mod/delete.dart +++ b/lib/commands/app/mod/delete.dart @@ -7,8 +7,8 @@ import "package:discloud/utils/messages.dart"; final class AppModDeleteCommand extends Command { AppModDeleteCommand() { argParser - ..addOption("app", mandatory: true) - ..addOption("mod", mandatory: true); + ..addOption("app") + ..addOption("mod"); } @override @@ -19,12 +19,12 @@ final class AppModDeleteCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); - final modId = argResults!.option("mod"); + final appId = argResults!.requiredOptionOrRest("app"); + final modId = argResults!.requiredOptionOrRest("mod", ["app"]); final spinner = context.printer.spin(text: "Deleting app MOD..."); - final response = await context.api.delete("/app/$appId/team$modId"); + final response = await context.api.delete("/app/$appId/team/$modId"); spinner.success(resolveResponseMessage(response)); } diff --git a/lib/commands/app/mod/edit.dart b/lib/commands/app/mod/edit.dart index f8eeded..a80ecef 100644 --- a/lib/commands/app/mod/edit.dart +++ b/lib/commands/app/mod/edit.dart @@ -8,8 +8,8 @@ import "package:discloud/utils/messages.dart"; final class AppModEditCommand extends Command { AppModEditCommand() { argParser - ..addOption("app", mandatory: true) - ..addOption("mod", mandatory: true) + ..addOption("app") + ..addOption("mod") ..addMultiOption("perms", allowed: appModPerms); } @@ -21,8 +21,8 @@ final class AppModEditCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); - final modId = argResults!.option("mod"); + final appId = argResults!.requiredOptionOrRest("app"); + final modId = argResults!.requiredOptionOrRest("mod", ["app"]); final perms = argResults!.multiOption("perms"); final spinner = context.printer.spin(text: "Editing app MOD..."); diff --git a/lib/commands/app/mod/info.dart b/lib/commands/app/mod/info.dart index f8909b3..85d46fa 100644 --- a/lib/commands/app/mod/info.dart +++ b/lib/commands/app/mod/info.dart @@ -1,6 +1,4 @@ import "dart:async"; -import "dart:io"; - import "package:args/command_runner.dart"; import "package:discloud/extensions/command.dart"; import "package:discloud/utils/ascii_table.dart"; @@ -8,7 +6,7 @@ import "package:discloud/utils/messages.dart"; final class AppModInfoCommand extends Command { AppModInfoCommand() { - argParser.addOption("app", mandatory: true); + argParser.addOption("app"); } @override @@ -19,7 +17,7 @@ final class AppModInfoCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final spinner = context.printer.spin(text: "Fetching app MODs..."); @@ -27,8 +25,8 @@ final class AppModInfoCommand extends Command { spinner.success(resolveResponseMessage(response)); - if (response["app"] case final Map data) { - stdout.writeln(mapToVerticalAsciiTable(data)); + if (response["team"] case final List list when list.isNotEmpty) { + context.printer.writeln(listToAsciiTable(list)); } } } diff --git a/lib/commands/app/profile.dart b/lib/commands/app/profile.dart index 81dbc03..5b0d5f6 100644 --- a/lib/commands/app/profile.dart +++ b/lib/commands/app/profile.dart @@ -7,7 +7,7 @@ import "package:discloud/utils/messages.dart"; final class AppProfileCommand extends Command { AppProfileCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addOption("name") ..addOption("avatar"); } @@ -21,7 +21,7 @@ final class AppProfileCommand extends Command { @override Future run() async { - final appId = argResults!.option("app")!; + final appId = argResults!.requiredOptionOrRest("app"); final name = argResults?.option("name"); final avatar = argResults?.option("avatar"); diff --git a/lib/commands/app/ram.dart b/lib/commands/app/ram.dart index 03aa634..bedc086 100644 --- a/lib/commands/app/ram.dart +++ b/lib/commands/app/ram.dart @@ -9,11 +9,10 @@ import "package:discloud_config/discloud_config.dart"; final class AppRamCommand extends Command { AppRamCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addOption( "amount", aliases: const ["ram"], - mandatory: true, valueHelp: DiscloudRamMinByType.lowest.value.toString(), ); } @@ -26,9 +25,9 @@ final class AppRamCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final ramMB = max( - int.parse(argResults!.option("amount")!), + argResults!.requiredIntOptionOrRest("amount", ["app"]), DiscloudRamMinByType.lowest.value, ); diff --git a/lib/commands/app/restart.dart b/lib/commands/app/restart.dart index 7971977..cfa8ac6 100644 --- a/lib/commands/app/restart.dart +++ b/lib/commands/app/restart.dart @@ -17,7 +17,7 @@ final class AppRestartCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Restarting app..."); diff --git a/lib/commands/app/start.dart b/lib/commands/app/start.dart index 8e78a21..4e8df33 100644 --- a/lib/commands/app/start.dart +++ b/lib/commands/app/start.dart @@ -17,7 +17,7 @@ final class AppStartCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Starting app..."); diff --git a/lib/commands/app/status.dart b/lib/commands/app/status.dart index 7f52666..b854115 100644 --- a/lib/commands/app/status.dart +++ b/lib/commands/app/status.dart @@ -10,7 +10,7 @@ const _keysToIgnore = {"netIO"}; final class AppStatusCommand extends Command { AppStatusCommand() { - argParser.addOption("app", mandatory: true); + argParser.addOption("app"); } @override @@ -21,7 +21,7 @@ final class AppStatusCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final spinner = context.printer.spin(text: "Fetching app status..."); diff --git a/lib/commands/app/stop.dart b/lib/commands/app/stop.dart index d3d8d53..4cbb365 100644 --- a/lib/commands/app/stop.dart +++ b/lib/commands/app/stop.dart @@ -17,7 +17,7 @@ final class AppStopCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Stopping app..."); diff --git a/lib/commands/app/upload.dart b/lib/commands/app/upload.dart index 5d96e0b..a3b73a3 100644 --- a/lib/commands/app/upload.dart +++ b/lib/commands/app/upload.dart @@ -14,7 +14,7 @@ import "package:path/path.dart" hide context; final class AppUploadCommand extends Command with Disposable { AppUploadCommand() { - argParser.addMultiOption("glob", abbr: "g", defaultsTo: const ["**"]); + argParser.addMultiOption("glob", abbr: "g", valueHelp: "**"); } @override @@ -39,7 +39,7 @@ final class AppUploadCommand extends Command with Disposable { await config.validate(); - final glob = argResults!.multiOption("glob"); + final glob = argResults!.multiOptionOrRest("glob") ?? const ["**"]; final spinner = context.printer.spin(text: "Zipping..."); @@ -77,7 +77,6 @@ final class AppUploadCommand extends Command with Disposable { ); }, onUploadDone: () { - _file = null; spinner.start("Processing..."); }, ); diff --git a/lib/commands/domain/create.dart b/lib/commands/domain/create.dart index 3fafc3f..8fe867f 100644 --- a/lib/commands/domain/create.dart +++ b/lib/commands/domain/create.dart @@ -9,8 +9,8 @@ import "package:discloud/utils/messages.dart"; final class CustomdomainCreateCommand extends Command { CustomdomainCreateCommand() { argParser - ..addOption("id", aliases: const ["domain"], mandatory: true) - ..addOption("app", aliases: const ["subdomain"], mandatory: true); + ..addOption("id", aliases: const ["domain"]) + ..addOption("app", aliases: const ["subdomain"]); } @override @@ -21,10 +21,10 @@ final class CustomdomainCreateCommand extends Command { @override Future run() async { - final String domain = argResults!.option("id")!; - final String subdomain = argResults!.option("app")!; + final domain = argResults!.requiredOptionOrRest("id"); + final subdomain = argResults!.requiredOptionOrRest("app", ["id"]); - final spinner = context.printer.spin(text: "Creating domain..."); + final spinner = context.printer.spin(text: "Creating $domain..."); final response = await context.api.post( "/customdomain/create", diff --git a/lib/commands/domain/delete.dart b/lib/commands/domain/delete.dart index bbf0749..a52d709 100644 --- a/lib/commands/domain/delete.dart +++ b/lib/commands/domain/delete.dart @@ -6,7 +6,7 @@ import "package:discloud/utils/messages.dart"; final class CustomdomainDeleteCommand extends Command { CustomdomainDeleteCommand() { - argParser.addOption("id", aliases: const ["domain"], mandatory: true); + argParser.addOption("id", aliases: const ["domain"]); } @override @@ -17,9 +17,9 @@ final class CustomdomainDeleteCommand extends Command { @override Future run() async { - final String domain = argResults!.option("id")!; + final domain = argResults!.requiredOptionOrRest("id"); - final spinner = context.printer.spin(text: "Deleting domain..."); + final spinner = context.printer.spin(text: "Deleting $domain..."); final response = await context.api.delete("/customdomain/$domain"); diff --git a/lib/commands/domain/edit.dart b/lib/commands/domain/edit.dart index 9ce2d97..bf1532b 100644 --- a/lib/commands/domain/edit.dart +++ b/lib/commands/domain/edit.dart @@ -8,8 +8,8 @@ import "package:discloud/utils/messages.dart"; final class CustomdomainEditCommand extends Command with Disposable { CustomdomainEditCommand() { argParser - ..addOption("id", aliases: const ["domain"], mandatory: true) - ..addOption("app", aliases: const ["subdomain"], mandatory: true); + ..addOption("id", aliases: const ["domain"]) + ..addOption("app", aliases: const ["subdomain"]); } @override @@ -20,10 +20,10 @@ final class CustomdomainEditCommand extends Command with Disposable { @override Future run() async { - final String domain = argResults!.option("id")!; - final String subdomain = argResults!.option("app")!; + final domain = argResults!.requiredOptionOrRest("id"); + final subdomain = argResults!.requiredOptionOrRest("app", ["id"]); - final spinner = context.printer.spin(text: "Editting a domain..."); + final spinner = context.printer.spin(text: "Editting $domain..."); final response = await context.api.put( "/customdomain/$domain/edit", diff --git a/lib/commands/domain/info.dart b/lib/commands/domain/info.dart index 665df61..a8cb58b 100644 --- a/lib/commands/domain/info.dart +++ b/lib/commands/domain/info.dart @@ -8,7 +8,7 @@ import "package:discloud/utils/messages.dart"; final class CustomdomainInfoCommand extends Command { CustomdomainInfoCommand() { - argParser.addOption("id", aliases: const ["domain"], defaultsTo: "all"); + argParser.addOption("id", aliases: const ["domain"], valueHelp: "all"); } @override @@ -19,15 +19,15 @@ final class CustomdomainInfoCommand extends Command { @override Future run() async { - final String domain = argResults!.option("id")!; + final domain = argResults!.optionOrRest("id") ?? "all"; - final spinner = context.printer.spin(text: "Fetching domain info..."); + final spinner = context.printer.spin(text: "Fetching $domain..."); final response = await context.api.get("/customdomain/$domain"); spinner.success(resolveResponseMessage(response)); - switch (response["customdomain"] ?? response["customdomains"]) { + switch (response["domain"] ?? response["domains"]) { case final List list: stdout.writeln(listToAsciiTable(list)); break; diff --git a/lib/commands/domain/verify.dart b/lib/commands/domain/verify.dart index 07027ac..c6fda73 100644 --- a/lib/commands/domain/verify.dart +++ b/lib/commands/domain/verify.dart @@ -8,7 +8,7 @@ import "package:discloud/utils/messages.dart"; final class CustomdomainVerifyCommand extends Command { CustomdomainVerifyCommand() { - argParser.addOption("id", aliases: const ["domain"], mandatory: true); + argParser.addOption("id", aliases: const ["domain"]); } @override @@ -19,9 +19,9 @@ final class CustomdomainVerifyCommand extends Command { @override Future run() async { - final String domain = argResults!.option("id")!; + final domain = argResults!.requiredOptionOrRest("id"); - final spinner = context.printer.spin(text: "Verifying a domain..."); + final spinner = context.printer.spin(text: "Verifying $domain..."); final response = await context.api.get("/customdomain/$domain/verify"); diff --git a/lib/commands/subdomain/create.dart b/lib/commands/subdomain/create.dart index ca30588..88d0416 100644 --- a/lib/commands/subdomain/create.dart +++ b/lib/commands/subdomain/create.dart @@ -8,7 +8,7 @@ import "package:discloud/utils/messages.dart"; final class SubdomainCreateCommand extends Command { SubdomainCreateCommand() { - argParser.addOption("id", aliases: const ["subdomain"], mandatory: true); + argParser.addOption("id", aliases: const ["subdomain"]); } @override @@ -19,9 +19,9 @@ final class SubdomainCreateCommand extends Command { @override Future run() async { - final String id = argResults!.option("id")!; + final id = argResults!.requiredOptionOrRest("id"); - final spinner = context.printer.spin(text: "Creating subdomain..."); + final spinner = context.printer.spin(text: "Creating $id..."); final response = await context.api.post("/subdomain/$id"); diff --git a/lib/commands/subdomain/delete.dart b/lib/commands/subdomain/delete.dart index 39dfec9..85dec9d 100644 --- a/lib/commands/subdomain/delete.dart +++ b/lib/commands/subdomain/delete.dart @@ -6,7 +6,7 @@ import "package:discloud/utils/messages.dart"; final class SubdomainDeleteCommand extends Command { SubdomainDeleteCommand() { - argParser.addOption("id", aliases: const ["subdomain"], mandatory: true); + argParser.addOption("id", aliases: const ["subdomain"]); } @override @@ -17,9 +17,9 @@ final class SubdomainDeleteCommand extends Command { @override Future run() async { - final String id = argResults!.option("id")!; + final id = argResults!.requiredOptionOrRest("id"); - final spinner = context.printer.spin(text: "Deleting subdomain..."); + final spinner = context.printer.spin(text: "Deleting $id..."); final response = await context.api.delete("/subdomain/$id"); diff --git a/lib/commands/subdomain/info.dart b/lib/commands/subdomain/info.dart index 0c3abb6..7521e2c 100644 --- a/lib/commands/subdomain/info.dart +++ b/lib/commands/subdomain/info.dart @@ -8,7 +8,7 @@ import "package:discloud/utils/messages.dart"; final class SubdomainInfoCommand extends Command { SubdomainInfoCommand() { - argParser.addOption("id", aliases: const ["subdomain"], defaultsTo: "all"); + argParser.addOption("id", aliases: const ["subdomain"], valueHelp: "all"); } @override @@ -19,9 +19,9 @@ final class SubdomainInfoCommand extends Command { @override Future run() async { - final String id = argResults!.option("id")!; + final id = argResults!.optionOrRest("id") ?? "all"; - final spinner = context.printer.spin(text: "Fetching subdomain info..."); + final spinner = context.printer.spin(text: "Fetching $id..."); final response = await context.api.get("/subdomain/$id"); diff --git a/lib/commands/team/backup.dart b/lib/commands/team/backup.dart index c3b2732..4f6c9e7 100644 --- a/lib/commands/team/backup.dart +++ b/lib/commands/team/backup.dart @@ -10,17 +10,16 @@ import "package:discloud/utils/download.dart"; import "package:discloud/utils/messages.dart"; import "package:discloud/utils/progress.dart"; import "package:discloud/utils/speed_monitor.dart"; - -const _pSep = "/"; +import "package:path/path.dart" hide context; final class TeamBackupCommand extends Command with Disposable { TeamBackupCommand() { argParser - ..addOption("app", mandatory: true, valueHelp: "all") + ..addOption("app", valueHelp: "all") ..addOption( "dir", abbr: "d", - aliases: const ["out"], + aliases: const ["out", "path"], help: "Specifies the destination path for downloading backups. The destination path will be considered a directory.", ); @@ -41,7 +40,8 @@ final class TeamBackupCommand extends Command with Disposable { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; + final dir = argResults!.optionOrRest("dir", ["app"]); final spinner = context.printer.spin(text: "Fetching backup..."); @@ -61,7 +61,7 @@ final class TeamBackupCommand extends Command with Disposable { Future _handleSingle(Map data, ISpin spinner) async { if (data["url"] case final String url) { - if (argResults?.option("dir") case final dir?) { + if (argResults!.optionOrRest("dir", ["app"]) case final dir) { final Uri uri = .parse(url); return _download(dir: dir, spinner: spinner, uri: uri); @@ -73,7 +73,7 @@ final class TeamBackupCommand extends Command with Disposable { Future _handleMulti(List list, ISpin spinner) async { final client = _client = .new(); - final dir = argResults?.option("dir") ?? "."; + final dir = argResults?.optionOrRest("dir") ?? "."; for (final data in list) { final String appId = data["id"]; @@ -99,7 +99,7 @@ final class TeamBackupCommand extends Command with Disposable { HttpClient? client, }) async { final filename = uri.pathSegments.last; - final filepath = "$dir$_pSep$filename"; + final filepath = joinAll([dir, filename]); final file = _file = .new(filepath); final monitor = _monitor = .new(); diff --git a/lib/commands/team/commit.dart b/lib/commands/team/commit.dart index 1bfb7af..3fe2459 100644 --- a/lib/commands/team/commit.dart +++ b/lib/commands/team/commit.dart @@ -39,12 +39,15 @@ final class TeamCommitCommand extends Command with Disposable { Future run() async { final directory = context.workspaceFolder; + final optionAppId = argResults?.option("app"); final appId = - argResults!.option("app") ?? await _getDiscloudConfigAppId(directory); + optionAppId ?? + argResults?.rest.firstOrNull ?? + await _getDiscloudConfigAppId(directory); if (appId == null) throw Exception("Missing app id"); - final glob = argResults!.multiOption("glob"); + final glob = argResults?.multiOptionOrRest("glob", [if (optionAppId != null) "app"]) ?? const ["**"]; final spinner = context.printer.spin(text: "Zipping..."); @@ -82,7 +85,6 @@ final class TeamCommitCommand extends Command with Disposable { ); }, onUploadDone: () { - _file = null; spinner.start("Processing..."); }, ); diff --git a/lib/commands/team/info.dart b/lib/commands/team/info.dart index 0484575..7364f3c 100644 --- a/lib/commands/team/info.dart +++ b/lib/commands/team/info.dart @@ -32,7 +32,7 @@ final class TeamInfoCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app"); final spinner = context.printer.spin(text: "Fetching app info..."); diff --git a/lib/commands/team/logs.dart b/lib/commands/team/logs.dart index c0e0125..65ffad2 100644 --- a/lib/commands/team/logs.dart +++ b/lib/commands/team/logs.dart @@ -13,13 +13,13 @@ final class TeamLogsCommand extends Command { argParser ..addOption( "app", - mandatory: true, valueHelp: "all", help: "When set to 'all', this command will automatically download logs and will not display URLs. If the 'out' option is not set, downloads will be made to the current folder.", ) ..addOption( "out", + aliases: const ["path"], help: "Specifies the destination path for downloading logs. When the application option is set to 'all', the destination path will be considered a directory where all downloads will be stored.", ); @@ -33,7 +33,8 @@ final class TeamLogsCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; + final out = argResults!.optionOrRest("out", ["app"]); final spinner = context.printer.spin(); @@ -43,40 +44,44 @@ final class TeamLogsCommand extends Command { switch (response["apps"]) { case final Map data: - await _handleSingle(data, argResults?.option("out"), spinner); + await _handleSingle(data, spinner, appId, out); break; case final List list: - await _handleMulti(list, spinner); + await _handleMulti(list, spinner, out ?? "."); break; } } Future _handleSingle( Map data, - String? out, ISpin spinner, + String appId, + String? out, ) async { - if (data["terminal"]?["big"] case final String data) { - if (out case final out?) return _saveLog(data, out, spinner); - context.printer.writeln(data); + if (data["terminal"]?["big"] case final String contents) { + if (out case final out?) { + return _saveLog(contents, _resolvePath(out, appId), spinner); + } + context.printer.writeln(contents); } } - Future _handleMulti(List list, ISpin spinner) async { - final out = argResults?.option("out") ?? "."; - + Future _handleMulti(List list, ISpin spinner, String out) async { for (final data in list) { final String appId = data["id"]; - final filename = "$appId.log"; - final filepath = joinAll([out, filename]); - await _handleSingle(data, filepath, spinner); + await _handleSingle(data, spinner, appId, out); } } Future _saveLog(String data, String out, ISpin spinner) async { - final file = File(out); - await file.create(recursive: true); + final File file = .new(out); + await file.parent.create(recursive: true); await file.writeAsString(stripAnsi(data)); spinner.success(out); } + + String _resolvePath(String out, String appId) { + if (extension(out).isNotEmpty) return out; + return joinAll([out, "$appId.log"]); + } } diff --git a/lib/commands/team/ram.dart b/lib/commands/team/ram.dart index c4222f4..b8d6843 100644 --- a/lib/commands/team/ram.dart +++ b/lib/commands/team/ram.dart @@ -9,11 +9,10 @@ import "package:discloud_config/discloud_config.dart"; final class TeamRamCommand extends Command { TeamRamCommand() { argParser - ..addOption("app", mandatory: true) + ..addOption("app") ..addOption( "amount", aliases: const ["ram"], - mandatory: true, valueHelp: DiscloudRamMinByType.lowest.value.toString(), ); } @@ -26,9 +25,9 @@ final class TeamRamCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final ramMB = max( - int.parse(argResults!.option("amount")!), + argResults!.requiredIntOptionOrRest("amount", ["app"]), DiscloudRamMinByType.lowest.value, ); diff --git a/lib/commands/team/restart.dart b/lib/commands/team/restart.dart index c0038b8..8b8acfc 100644 --- a/lib/commands/team/restart.dart +++ b/lib/commands/team/restart.dart @@ -17,7 +17,7 @@ final class TeamRestartCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Restarting app..."); diff --git a/lib/commands/team/start.dart b/lib/commands/team/start.dart index c7b6655..5966ad2 100644 --- a/lib/commands/team/start.dart +++ b/lib/commands/team/start.dart @@ -17,7 +17,7 @@ final class TeamStartCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Starting app..."); diff --git a/lib/commands/team/status.dart b/lib/commands/team/status.dart index b9b7430..9449601 100644 --- a/lib/commands/team/status.dart +++ b/lib/commands/team/status.dart @@ -10,7 +10,7 @@ const _keysToIgnore = {"netIO"}; final class TeamStatusCommand extends Command { TeamStatusCommand() { - argParser.addOption("app", mandatory: true); + argParser.addOption("app"); } @override @@ -21,7 +21,7 @@ final class TeamStatusCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.requiredOptionOrRest("app"); final spinner = context.printer.spin(text: "Fetching app status..."); diff --git a/lib/commands/team/stop.dart b/lib/commands/team/stop.dart index 3e3e523..baeb2be 100644 --- a/lib/commands/team/stop.dart +++ b/lib/commands/team/stop.dart @@ -17,7 +17,7 @@ final class TeamStopCommand extends Command { @override Future run() async { - final appId = argResults!.option("app"); + final appId = argResults!.optionOrRest("app") ?? "all"; final spinner = context.printer.spin(text: "Stopping app..."); diff --git a/lib/commands/zip.dart b/lib/commands/zip.dart index 2fe6c9e..0467c15 100644 --- a/lib/commands/zip.dart +++ b/lib/commands/zip.dart @@ -14,7 +14,7 @@ final class ZipCommand extends Command with Disposable { ZipCommand() { argParser ..addOption("encoding", abbr: "e", allowed: const ["buffer"], hide: true) - ..addMultiOption("glob", abbr: "g", defaultsTo: const ["**"]) + ..addMultiOption("glob", abbr: "g", valueHelp: const ["**"]) ..addOption("out", abbr: "o", help: "Zip output") ..addOption( "level", @@ -44,7 +44,7 @@ final class ZipCommand extends Command with Disposable { final encoding = argResults?.option("encoding"); final out = argResults?.option("out") ?? "${basename(directory.path)}.zip"; - final glob = argResults?.multiOption("glob") ?? const ["**"]; + final glob = argResults!.multiOptionOrRest("glob") ?? const ["**"]; final level = _compressionLevel; final password = argResults?.option("password"); diff --git a/lib/extensions/arg_results.dart b/lib/extensions/arg_results.dart index 6d5f2ce..e4cbc1d 100644 --- a/lib/extensions/arg_results.dart +++ b/lib/extensions/arg_results.dart @@ -1,4 +1,5 @@ import "package:args/args.dart"; +import "package:args/command_runner.dart"; extension NullableArgResultsExtension on ArgResults? { String? get commandName { @@ -15,3 +16,63 @@ extension NullableArgResultsExtension on ArgResults? { return list.join(" "); } } + +extension ArgResultsExtension on ArgResults { + bool wasNotParsed(String name) { + return !wasParsed(name); + } + + String? optionOrRest(String name, [Iterable after = const []]) { + if (wasParsed(name)) return option(name); + + final index = after.where(wasNotParsed).length; + + if (rest.length > index) return rest[index]; + + return null; + } + + String requiredOptionOrRest( + String name, [ + Iterable after = const [], + ]) { + final value = optionOrRest(name, after); + if (value != null) return value; + + throw UsageException("Missing required option or argument: $name", ""); + } + + int requiredIntOptionOrRest( + String name, [ + Iterable after = const [], + ]) { + final raw = requiredOptionOrRest(name, after); + final value = int.tryParse(raw); + if (value != null) return value; + + throw UsageException("Invalid integer for $name: $raw", ""); + } + + List? multiOptionOrRest( + String name, [ + Iterable after = const [], + ]) { + if (wasParsed(name)) return multiOption(name); + + final index = after.where(wasNotParsed).length; + + if (rest.length > index) return rest.skip(index).toList(); + + return null; + } + + List requiredMultiOptionOrRest( + String name, [ + Iterable after = const [], + ]) { + final values = multiOptionOrRest(name, after); + if (values != null && values.isNotEmpty) return values; + + throw UsageException("Missing required option or argument: $name", ""); + } +} diff --git a/lib/extensions/command.dart b/lib/extensions/command.dart index 4d2b1f9..8d32b69 100644 --- a/lib/extensions/command.dart +++ b/lib/extensions/command.dart @@ -1,6 +1,8 @@ import "package:args/command_runner.dart"; import "package:discloud/cli/context.dart"; +export "package:discloud/extensions/arg_results.dart"; + extension CommandExtension on Command { CliContext get context => .I; diff --git a/lib/services/discloud/api_client.dart b/lib/services/discloud/api_client.dart index 21c59be..9f7bb3b 100644 --- a/lib/services/discloud/api_client.dart +++ b/lib/services/discloud/api_client.dart @@ -271,8 +271,8 @@ class DiscloudApiClient implements Disposable { }) async { final now = DateTime.now(); final boundary = "formBoundary${now.microsecondsSinceEpoch}"; - final filename = file.uri.pathSegments.last; - final fileExtension = extension(filename); + final filename = _escapeMultipartValue(file.uri.pathSegments.last); + final contentType = _multipartContentType(file); await _prepareRequest(request, headers: headers ??= {}); @@ -287,7 +287,7 @@ class DiscloudApiClient implements Disposable { request.write( "--$boundary\r\n" "Content-Disposition: form-data; " - 'name="${e.key}"\r\n\r\n${e.value}\r\n', + 'name="${_escapeMultipartValue(e.key)}"\r\n\r\n${e.value}\r\n', ); } } @@ -297,7 +297,7 @@ class DiscloudApiClient implements Disposable { "Content-Disposition: form-data; " 'name="file"; ' 'filename="$filename"\r\n' - "Content-Type: application/$fileExtension\r\n\r\n", + "Content-Type: $contentType\r\n\r\n", ); int processed = 0; @@ -314,6 +314,20 @@ class DiscloudApiClient implements Disposable { await onUploadDone?.call(); } + String _escapeMultipartValue(String value) { + return value + .replaceAll('"', "%22") + .replaceAll("\r", "") + .replaceAll("\n", ""); + } + + String _multipartContentType(File file) { + return switch (extension(file.path).toLowerCase()) { + ".zip" => "application/zip", + _ => "application/octet-stream", + }; + } + Future _prepareRequest( HttpClientRequest request, { Map? headers, diff --git a/lib/utils/ascii_table.dart b/lib/utils/ascii_table.dart index 58748a5..96c89f9 100644 --- a/lib/utils/ascii_table.dart +++ b/lib/utils/ascii_table.dart @@ -4,6 +4,8 @@ String mapToVerticalAsciiTable( Map data, [ Set keysToIgnore = const {}, ]) { + if (data.isEmpty) return ""; + final List> rows = []; for (final entry in data.entries) { @@ -24,13 +26,25 @@ String listToAsciiTable( List list, [ Set keysToIgnore = const {}, ]) { + if (list.isEmpty) return ""; + + final first = list.first; + if (first is! Map) { + return tabular([ + ["#", "value"], + for (int i = 0; i < list.length; i++) [i + 1, list[i]], + ]); + } + final keys = []; final List> rows = [keys]; - keys.addAll(["#", ...list[0].keys.where((k) => !keysToIgnore.contains(k))]); + keys.addAll(["#", ...first.keys.where((k) => !keysToIgnore.contains(k))]); for (int i = 0; i < list.length; i++) { final data = list[i]; + if (data is! Map) continue; + final row = [i + 1]; rows.add(row); diff --git a/lib/utils/bytes.dart b/lib/utils/bytes.dart index 9c2f3c1..b467e10 100644 --- a/lib/utils/bytes.dart +++ b/lib/utils/bytes.dart @@ -40,10 +40,10 @@ class Bytes { double get bytes { if (_isInvalid) return 0; - return n / pow(dimension.value, index); + return n / pow(dimension.value, unitIndex); } - int get unitIndex => index.clamp(0, type.units.length); + int get unitIndex => index.clamp(0, type.units.length - 1); bool get _isInvalid => n.isNaN || n.isInfinite || n.isZero || n.isNegative; @@ -165,7 +165,7 @@ class _Bits extends Bytes { UnitType get type => .bit; @override - int get unitIndex => index.clamp(0, type.units.length); + int get unitIndex => index.clamp(0, type.units.length - 1); @override String toString([String separator = ""]) { diff --git a/lib/utils/download.dart b/lib/utils/download.dart index 9276059..af50fe7 100644 --- a/lib/utils/download.dart +++ b/lib/utils/download.dart @@ -1,5 +1,7 @@ import "dart:io"; +import "package:discloud/extensions/io_http_client.dart"; + typedef VoidProgressCallback = void Function(int processed, int total); Future download( @@ -8,23 +10,38 @@ Future download( HttpClient? client, VoidProgressCallback? onProgress, }) async { - file ??= .new(url.pathSegments.last); - await file.create(recursive: true); - final sink = file.openWrite(); - final client0 = client ?? .new(); + final request = await client0.getUrl(url); + + HttpClientResponse? response; try { - final request = await client0.getUrl(url); + response = await request.close(); + if (!response.ok) { + throw HttpException( + "Download failed: ${response.statusCode} ${response.reasonPhrase}", + uri: url, + ); + } + } catch (e) { + rethrow; + } finally { + if (client == null) client0.close(); + } + + file ??= .new(url.pathSegments.last); + await file.parent.create(recursive: true); + final sink = file.openWrite(); + + try { if (onProgress case final onProgress?) { await _downloadWithProgress( onProgress: onProgress, - responseFactory: request.close, + response: response, sink: sink, ); } else { - final response = await request.close(); await sink.addStream(response); } } catch (_) { @@ -39,11 +56,9 @@ Future download( Future _downloadWithProgress({ required VoidProgressCallback onProgress, - required Future Function() responseFactory, + required HttpClientResponse response, required IOSink sink, }) async { - final response = await responseFactory(); - final total = response.contentLength; int processed = 0; diff --git a/lib/utils/progress.dart b/lib/utils/progress.dart index e772d0a..2ae2542 100644 --- a/lib/utils/progress.dart +++ b/lib/utils/progress.dart @@ -1,3 +1,4 @@ +import "package:discloud/extensions/num.dart"; import "package:discloud/utils/bytes.dart"; import "package:discloud/utils/formatters.dart"; @@ -24,10 +25,16 @@ String formatProgressMessage({ final StringBuffer buffer = .new(prefixText); if (speed case final speed?) { - buffer.writeAll([" ", ?direction, Bytes.bits(speed * 8), "/s "]); + buffer.writeAll([" ", ?direction, Bytes.bits(speed * 8), "/s"]); } - buffer.write(percentFormatter.format(processed / total)); + buffer.write(" "); + + if (total.isNegativeOrZero) { + buffer.write(Bytes(processed).toString(" ")); + } else { + buffer.write(percentFormatter.format(processed / total)); + } return buffer.toString(); } diff --git a/modules/glob_zipper/lib/src/fs.dart b/modules/glob_zipper/lib/src/fs.dart index b9d458b..ec71969 100644 --- a/modules/glob_zipper/lib/src/fs.dart +++ b/modules/glob_zipper/lib/src/fs.dart @@ -56,7 +56,10 @@ class FS { final visitedDirectories = {}; - await for (final entity in _glob.list(root: directory.path)) { + await for (final entity in _glob.list( + root: directory.path, + followLinks: false, + )) { if (entity is! File || ignore.matches(entity.path)) continue; final folder = entity.dirname; @@ -73,7 +76,10 @@ class FS { Stream _listWithoutIgnoreFilename() async* { final Glob ignore = _ignoreGlob; - await for (final entity in _glob.list(root: directory.path)) { + await for (final entity in _glob.list( + root: directory.path, + followLinks: false, + )) { if (entity is! File || ignore.matches(entity.path)) continue; yield entity as File; @@ -81,7 +87,7 @@ class FS { } Future _resolveIgnoreFile(String folder, String filename) async { - final File file = .new("$folder$_pSep$filename"); + final File file = .new(joinAll([folder, filename])); if (!await file.exists()) return false; diff --git a/modules/local_store/lib/src/json_local_store.dart b/modules/local_store/lib/src/json_local_store.dart index b4a6639..1f1f52e 100644 --- a/modules/local_store/lib/src/json_local_store.dart +++ b/modules/local_store/lib/src/json_local_store.dart @@ -33,8 +33,12 @@ class _JSONLocalStore implements LocalStore { } Future _save() async { - if (!await _file.exists()) await _file.create(recursive: true); - await _file.writeAsBytes(_jsonBase64Encoder.convert(_cache), flush: true); + await _file.parent.create(recursive: true); + + final temp = File("${_file.path}.tmp"); + await temp.writeAsBytes(_jsonBase64Encoder.convert(_cache), flush: true); + + await temp.rename(_file.path); } @override diff --git a/test/utils/ascii_table_test.dart b/test/utils/ascii_table_test.dart new file mode 100644 index 0000000..18c5cee --- /dev/null +++ b/test/utils/ascii_table_test.dart @@ -0,0 +1,18 @@ +import "package:discloud/utils/ascii_table.dart"; +import "package:test/test.dart"; + +void main() { + group("ascii table", () { + test("empty maps do not throw", () { + expect(mapToVerticalAsciiTable(const {}), ""); + }); + + test("empty lists do not throw", () { + expect(listToAsciiTable(const []), ""); + }); + + test("scalar lists do not throw", () { + expect(listToAsciiTable(const ["ok"]), contains("ok")); + }); + }); +} diff --git a/test/utils/bytes_test.dart b/test/utils/bytes_test.dart index 2365bd5..25902c0 100644 --- a/test/utils/bytes_test.dart +++ b/test/utils/bytes_test.dart @@ -12,6 +12,11 @@ void main() { const Bytes bytes = .new(1024 * 1024); expect(bytes.toString(), "1.0MB"); }); + + test("huge values clamp to the largest unit", () { + const Bytes bytes = .new(1e30); + expect(bytes.toString().endsWith("YB"), isTrue); + }); }); group("Bits", () { diff --git a/test/utils/progress_test.dart b/test/utils/progress_test.dart new file mode 100644 index 0000000..f646019 --- /dev/null +++ b/test/utils/progress_test.dart @@ -0,0 +1,13 @@ +import "package:discloud/utils/progress.dart"; +import "package:test/test.dart"; + +void main() { + group("formatProgressMessage", () { + test("handles unknown totals", () { + expect( + formatProgressMessage(processed: 1024, total: -1), + contains("1.0 KB"), + ); + }); + }); +}