diff --git a/packages/cli_tools/lib/execute.dart b/packages/cli_tools/lib/execute.dart new file mode 100644 index 0000000..26eb968 --- /dev/null +++ b/packages/cli_tools/lib/execute.dart @@ -0,0 +1 @@ +export 'src/execute/execute.dart'; diff --git a/packages/cli_tools/lib/src/execute/execute.dart b/packages/cli_tools/lib/src/execute/execute.dart new file mode 100644 index 0000000..7545548 --- /dev/null +++ b/packages/cli_tools/lib/src/execute/execute.dart @@ -0,0 +1,74 @@ +import 'dart:io'; +import 'dart:io' as io; // to distinguish stdout from io.stdout, etc. + +import 'package:async/async.dart'; + +/// Executes a [command] in a child process shell and returns the exit code. +/// +/// The [command] is a shell command line that can include arguments, e.g., +/// `echo "Hello world!"`. +/// +/// Child stdout/stderr will be forwarded to the parent process. It will use the +/// parent's defaults for [stdin]/[stdout] unless overridden with alternative +/// [IOSink]s. +/// +/// Parent signals (SIGINT & SIGTERM) will be forwarded to the child, while +/// [command] is running +/// +/// If you pass a [stdin] stream then it will be consumed and forwarded to the +/// child. If you plan on listening to stdin again later, make sure to convert +/// it from a single subscription stream first. +/// +/// You can specify what [workingDirectory] the child process should be spawned +/// in. It will default to [Directory.current]. +Future execute( + final String command, { + final Stream>? stdin, + IOSink? stdout, + IOSink? stderr, + Directory? workingDirectory, +}) async { + stdout ??= io.stdout; + stderr ??= io.stderr; + workingDirectory ??= Directory.current; + + final shell = Platform.isWindows ? 'cmd' : 'bash'; + final shellArg = Platform.isWindows ? '/c' : '-c'; + + // NOTE: We invoke a shell instead of the command directly (with runInShell: + // true). This avoid a lot of edge cases regarding quoting, repeated spaces, + // etc. + final process = await Process.start( + shell, + [shellArg, command], + workingDirectory: workingDirectory.path, + ); + + // Forward signals to child process + final sigSubscription = StreamGroup.merge( + [ + ProcessSignal.sigint, + if (!Platform.isWindows) ProcessSignal.sigterm, + ].map((final s) => s.watch()), + ).listen((final s) { + process.kill(s); + }); + + // Forward stdin to the child process + final stdinSubscription = stdin?.listen( + process.stdin.add, + cancelOnError: true, + onError: (final _) {}, // extremely unlikely, but why not + ); + + // Stream output directly to terminal + await [ + stdout.addStream(process.stdout), + stderr.addStream(process.stderr), + ].wait; + await stdinSubscription?.cancel(); + await process.stdin.close(); + await sigSubscription.cancel(); + + return await process.exitCode; +} diff --git a/packages/cli_tools/pubspec.yaml b/packages/cli_tools/pubspec.yaml index 03bfabe..6232efc 100644 --- a/packages/cli_tools/pubspec.yaml +++ b/packages/cli_tools/pubspec.yaml @@ -16,6 +16,7 @@ environment: dependencies: args: ^2.7.0 + async: ^2.10.0 ci: ^0.1.0 config: ^0.8.3 http: '>=0.13.0 <2.0.0' diff --git a/packages/cli_tools/test/execute_driver.dart b/packages/cli_tools/test/execute_driver.dart new file mode 100644 index 0000000..c147f3b --- /dev/null +++ b/packages/cli_tools/test/execute_driver.dart @@ -0,0 +1,5 @@ +import 'dart:io'; + +import 'package:cli_tools/execute.dart'; + +void main(final List args) async => exit(await execute(args.join(' '))); diff --git a/packages/cli_tools/test/execute_test.dart b/packages/cli_tools/test/execute_test.dart new file mode 100644 index 0000000..eec1ce9 --- /dev/null +++ b/packages/cli_tools/test/execute_test.dart @@ -0,0 +1,78 @@ +// These test depends on bash and unix specific tools (trap, exit, echo) +@TestOn('!windows') +library; + +import 'dart:io'; + +import 'package:cli_tools/execute.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Future compileDriver() async { + final driverExe = p.join(Directory.systemTemp.path, 'execute_driver.exe'); + final result = await Process.run( + 'dart', ['compile', 'exe', 'test/execute_driver.dart', '-o', driverExe]); + if (result.exitCode != 0) throw StateError('Failed to compile driver'); + return driverExe; +} + +Future _exe = compileDriver(); + +Future runDriver(final String command) async => + await Process.run(await _exe, [command]); + +Future startDriver(final String command) async => + await Process.start(await _exe, [command]); + +void main() { + group('Given execute', () { + test( + 'when running a command that succeeds, then the effect is expected', + () async { + final result = await runDriver('echo "Hello world!"'); + expect(result.exitCode, 0); + expect(result.stdout, contains('Hello world!')); + }, + ); + + test( + 'when running a command that fails, then the exit code is propagated', + () async { + expect(await execute('exit 42'), 42); + }, + ); + + test( + 'when running a non-existent command, then an error happens', + () async { + final result = await runDriver('fhasjkhfs'); + expect(result.exitCode, isNot(0)); + expect(result.stderr, contains('not found')); + }, + ); + + test('when sending SIGINT, then it is forwarded to the child process', + () async { + // Use trap to catch signal in child + final process = await startDriver( + 'trap "echo SIGINT; exit 0" INT; echo "Running"; while :; do sleep 0.1; done'); + + // Collect stdout incrementally + final stdoutBuffer = StringBuffer(); + process.stdout.transform(systemEncoding.decoder).listen((final data) { + stdoutBuffer.write(data); + }); + + // Wait for the script to start (look for "Running" message) + while (!stdoutBuffer.toString().contains('Running')) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + // Send SIGINT to driver + process.kill(ProcessSignal.sigint); + + expect(await process.exitCode, 0); + expect(stdoutBuffer.toString(), contains('SIGINT')); + }); + }); +}