From 914bde512e4df52f518da91ae3bf308a9ecfa98a Mon Sep 17 00:00:00 2001 From: Pascal Welsch Date: Tue, 10 Jan 2023 17:56:49 +0100 Subject: [PATCH] Add BashCommand (#168) * Add BashCommand * Example how to load from file * Add tests * Default to withStdIn:false * Make error message easier to understand * Throw BashCommandException * Test arguments * Test all exception properties --- sidekick_core/lib/sidekick_core.dart | 1 + sidekick_core/lib/src/cli_util.dart | 14 +- .../lib/src/commands/bash_command.dart | 150 +++++++++++++++++ sidekick_core/lib/src/forward_command.dart | 2 +- sidekick_core/test/bash_command_test.dart | 157 ++++++++++++++++++ 5 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 sidekick_core/lib/src/commands/bash_command.dart create mode 100644 sidekick_core/test/bash_command_test.dart diff --git a/sidekick_core/lib/sidekick_core.dart b/sidekick_core/lib/sidekick_core.dart index 135f311f..f930a40c 100644 --- a/sidekick_core/lib/sidekick_core.dart +++ b/sidekick_core/lib/sidekick_core.dart @@ -20,6 +20,7 @@ export 'package:dcli/dcli.dart' hide run, start, startFromArgs, absolute; export 'package:pub_semver/pub_semver.dart' show Version; export 'package:sidekick_core/src/cli_util.dart'; export 'package:sidekick_core/src/commands/analyze_command.dart'; +export 'package:sidekick_core/src/commands/bash_command.dart'; export 'package:sidekick_core/src/commands/dart_command.dart'; export 'package:sidekick_core/src/commands/deps_command.dart'; export 'package:sidekick_core/src/commands/flutter_command.dart'; diff --git a/sidekick_core/lib/src/cli_util.dart b/sidekick_core/lib/src/cli_util.dart index d49934ec..f7855f47 100644 --- a/sidekick_core/lib/src/cli_util.dart +++ b/sidekick_core/lib/src/cli_util.dart @@ -36,21 +36,33 @@ io.File tempExecutableScriptFile(String content, {Directory? tempDir}) { } /// Executes a script by first writing it as file and then running it as shell script +/// +/// Use [args] to pass arguments to the script +/// +/// Use [workingDirectory] to set the working directory of the script, default +/// to current working directory +/// +/// When [terminal] is `true` (default: `false`) Stdio handles are inherited by +/// the child process. This allows stdin to read by the script dcli.Progress writeAndRunShellScript( String scriptContent, { + List args = const [], Directory? workingDirectory, dcli.Progress? progress, + bool terminal = false, }) { final script = tempExecutableScriptFile(scriptContent); final Progress scriptProgress = progress ?? Progress(print, stderr: printerr, captureStderr: true); try { - return dcli.start( + return dcli.startFromArgs( script.absolute.path, + args, workingDirectory: workingDirectory?.absolute.path ?? entryWorkingDirectory.path, progress: scriptProgress, + terminal: terminal, ); } catch (e) { print( diff --git a/sidekick_core/lib/src/commands/bash_command.dart b/sidekick_core/lib/src/commands/bash_command.dart new file mode 100644 index 00000000..1f1820a4 --- /dev/null +++ b/sidekick_core/lib/src/commands/bash_command.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:dcli/dcli.dart' as dcli; +import 'package:sidekick_core/sidekick_core.dart'; + +/// A Command that wraps a bash script +/// +/// Easy way to convert an existing bash script into a command for sidekick +/// +/// This makes it easy to handle paths within the bash script. You can define a +/// static [workingDirectory] and always know where the script is executed. +/// +/// Usage as plain text +/// ```dart +/// runner +/// ..addCommand( +/// BashCommand( +/// name: 'test-bash-command', +/// description: 'Prints inputs of a sidekick BashCommand', +/// workingDirectory: runner.repository.root, +/// script: () => ''' +/// echo "arguments: \$@" +/// echo "workingDirectory: \$(pwd)" +/// # Access paths from sidekick +/// ${systemFlutterSdkPath()}/bin/flutter --version +/// ''', +/// ), +/// ); +/// ``` +/// +/// Or load your script from a file +/// ```dart +/// runner +/// ..addCommand( +/// BashCommand( +/// name: 'test-bash-command', +/// description: 'Prints inputs of a sidekick BashCommand', +/// workingDirectory: runner.repository.root, +/// script: () => runner.repository.root +/// .file('scripts/test-bash-command.sh') +/// .readAsString(), +/// ), +/// ) +/// ``` +/// +/// If your script is interactive, set [withStdIn] to `true` to allow stdin to +/// be connected to the script. +class BashCommand extends ForwardCommand { + BashCommand({ + required this.script, + required this.description, + required this.name, + this.workingDirectory, + this.withStdIn = false, + }); + + @override + final String name; + + @override + final String description; + + /// The script to be executed. + /// + /// You may load this script from a file or generate it on the fly. + final FutureOr Function() script; + + /// The directory the bash script is running in. + final Directory? workingDirectory; + + /// Whether to forward stdin to the bash script, default to true + final bool withStdIn; + + @override + Future run() async { + final bashScript = await script(); + + final scriptFile = tempExecutableScriptFile(bashScript); + final Progress progress = + Progress(print, stderr: printerr, captureStderr: true); + + try { + dcli.startFromArgs( + scriptFile.absolute.path, + argResults!.arguments, + workingDirectory: + workingDirectory?.absolute.path ?? entryWorkingDirectory.path, + progress: progress, + terminal: withStdIn, + ); + } catch (e, stack) { + throw BashCommandException( + script: bashScript, + commandName: name, + arguments: argResults!.arguments, + exitCode: progress.exitCode!, + cause: e, + stack: stack, + ); + } finally { + scriptFile.deleteSync(recursive: true); + } + } +} + +/// Exception thrown when a [BashCommand] fails containing all the information +/// about the script and its error +class BashCommandException implements Exception { + /// The actual script content that was executed + final String script; + + /// The name of the command that executed this script + final String commandName; + + /// The arguments passed into the command + final List arguments; + + /// The exit code of the script that caused the error. (Always != `0`) + final int exitCode; + + /// The complete stacktrace, going into dcli which was ultimately executing this script + final StackTrace stack; + + /// The original exception from dcli + final Object cause; + + const BashCommandException({ + required this.script, + required this.commandName, + required this.arguments, + required this.exitCode, + required this.stack, + required this.cause, + }); + + @override + String toString() { + String args = arguments.joinToString(separator: ' '); + if (!args.isBlank) { + args = "($args)"; + } else { + args = ''; + } + return "Error (exitCode=$exitCode) executing script of command '$commandName' " + "with arguments: $args\n\n" + "'''bash\n" + "${script.trimRight()}\n" + "'''\n\n"; + } +} diff --git a/sidekick_core/lib/src/forward_command.dart b/sidekick_core/lib/src/forward_command.dart index 0fb912ad..bb8c69df 100644 --- a/sidekick_core/lib/src/forward_command.dart +++ b/sidekick_core/lib/src/forward_command.dart @@ -3,7 +3,7 @@ import 'package:args/command_runner.dart'; /// A [Command] which accepts all arguments and forwards everything to another cli app /// -/// Arguments to format are available via `argResults!.arguments` +/// Arguments are available via `argResults!.arguments`, not `argResults!.rest` abstract class ForwardCommand extends Command { ForwardCommand() { // recreate the _argParser and change it to allowAnything diff --git a/sidekick_core/test/bash_command_test.dart b/sidekick_core/test/bash_command_test.dart new file mode 100644 index 00000000..d07cd460 --- /dev/null +++ b/sidekick_core/test/bash_command_test.dart @@ -0,0 +1,157 @@ +import 'package:sidekick_core/sidekick_core.dart'; +import 'package:sidekick_test/fake_stdio.dart'; +import 'package:sidekick_test/sidekick_test.dart'; +import 'package:test/test.dart'; + +void main() { + test('bash command receives arguments', () async { + await insideFakeProjectWithSidekick((dir) async { + final runner = initializeSidekick( + name: 'dash', + dartSdkPath: fakeDartSdk().path, + ); + runner.addCommand( + BashCommand( + script: () => 'echo \$@', + description: 'description', + name: 'script', + ), + ); + final fakeStdOut = FakeStdoutStream(); + await overrideIoStreams( + stdout: () => fakeStdOut, + body: () => runner.run(['script', 'asdf', 'qwer']), + ); + + expect(fakeStdOut.lines, contains('asdf qwer')); + }); + }); + + test('workingDirectory default to cwd', () async { + await insideFakeProjectWithSidekick((dir) async { + final runner = initializeSidekick( + name: 'dash', + dartSdkPath: fakeDartSdk().path, + ); + runner.addCommand( + BashCommand( + script: () => 'echo \$PWD', + description: 'description', + name: 'script', + ), + ); + final fakeStdOut = FakeStdoutStream(); + await overrideIoStreams( + stdout: () => fakeStdOut, + body: () => runner.run(['script']), + ); + + expect(fakeStdOut.lines.join(), endsWith(Directory.current.path)); + }); + }); + + test('workingDirectory can be set', () async { + await insideFakeProjectWithSidekick((dir) async { + final subDir = dir.directory('someSubDir')..createSync(); + final runner = initializeSidekick( + name: 'dash', + dartSdkPath: fakeDartSdk().path, + ); + runner.addCommand( + BashCommand( + script: () => 'echo \$PWD', + description: 'description', + name: 'script', + workingDirectory: subDir, + ), + ); + final fakeStdOut = FakeStdoutStream(); + await overrideIoStreams( + stdout: () => fakeStdOut, + body: () => runner.run(['script']), + ); + + expect(fakeStdOut.lines.join(), endsWith(subDir.path)); + }); + }); + + test('throw BashCommandException on error', () async { + await insideFakeProjectWithSidekick((dir) async { + final runner = initializeSidekick( + name: 'dash', + dartSdkPath: fakeDartSdk().path, + ); + runner.addCommand( + BashCommand( + script: () => '#scriptContent\nexit 34', + description: 'description', + name: 'script', + ), + ); + try { + await runner.run(['script']); + fail('should throw'); + } catch (e) { + expect( + e, + isA() + .having((it) => it.exitCode, 'exitCode', 34) + .having((it) => it.script, 'script', contains('#scriptContent')) + .having((it) => it.commandName, 'commandName', 'script') + .having((it) => it.arguments, 'arguments', []) + .having((it) => it.cause, 'cause', isA()) + .having( + (it) => it.stack, + 'stack', + isA().having( + (it) => it.toString(), + 'toString', + contains('bash_command_test.dart'), + )) + .having( + (it) => it.toString(), + 'toString()', + contains('exitCode=34'), + ) + .having( + (it) => it.toString(), + 'toString()', + contains(''), + ), + ); + } + }); + }); + + test('BashCommandException contains arguments', () async { + await insideFakeProjectWithSidekick((dir) async { + final runner = initializeSidekick( + name: 'dash', + dartSdkPath: fakeDartSdk().path, + ); + runner.addCommand( + BashCommand( + script: () => '#scriptContent\nexit 34', + description: 'description', + name: 'script', + ), + ); + try { + await runner.run(['script', 'asdf', 'qwer']); + fail('should throw'); + } catch (e) { + expect( + e, + isA() + .having((it) => it.exitCode, 'exitCode', 34) + .having( + (it) => it.arguments, 'arguments', ['asdf', 'qwer']).having( + (it) => it.toString(), + 'toString()', + contains('asdf qwer'), + ), + ); + } + }); + }); +}