Skip to content

Commit

Permalink
Add BashCommand (#168)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
passsy authored Jan 10, 2023
1 parent 85fca55 commit 914bde5
Show file tree
Hide file tree
Showing 5 changed files with 322 additions and 2 deletions.
1 change: 1 addition & 0 deletions sidekick_core/lib/sidekick_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 13 additions & 1 deletion sidekick_core/lib/src/cli_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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(
Expand Down
150 changes: 150 additions & 0 deletions sidekick_core/lib/src/commands/bash_command.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<void> 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<String> 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 = '<no arguments>';
}
return "Error (exitCode=$exitCode) executing script of command '$commandName' "
"with arguments: $args\n\n"
"'''bash\n"
"${script.trimRight()}\n"
"'''\n\n";
}
}
2 changes: 1 addition & 1 deletion sidekick_core/lib/src/forward_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions sidekick_core/test/bash_command_test.dart
Original file line number Diff line number Diff line change
@@ -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<BashCommandException>()
.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<RunException>())
.having(
(it) => it.stack,
'stack',
isA<StackTrace>().having(
(it) => it.toString(),
'toString',
contains('bash_command_test.dart'),
))
.having(
(it) => it.toString(),
'toString()',
contains('exitCode=34'),
)
.having(
(it) => it.toString(),
'toString()',
contains('<no arguments>'),
),
);
}
});
});

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<BashCommandException>()
.having((it) => it.exitCode, 'exitCode', 34)
.having(
(it) => it.arguments, 'arguments', ['asdf', 'qwer']).having(
(it) => it.toString(),
'toString()',
contains('asdf qwer'),
),
);
}
});
});
}

0 comments on commit 914bde5

Please sign in to comment.