Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli_tools/lib/execute.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'src/execute/execute.dart';
74 changes: 74 additions & 0 deletions packages/cli_tools/lib/src/execute/execute.dart
Original file line number Diff line number Diff line change
@@ -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<int> execute(
final String command, {
final Stream<List<int>>? 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;
}
1 change: 1 addition & 0 deletions packages/cli_tools/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions packages/cli_tools/test/execute_driver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import 'dart:io';

import 'package:cli_tools/execute.dart';

void main(final List<String> args) async => exit(await execute(args.join(' ')));
78 changes: 78 additions & 0 deletions packages/cli_tools/test/execute_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> _exe = compileDriver();

Future<ProcessResult> runDriver(final String command) async =>
await Process.run(await _exe, [command]);

Future<Process> 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<void>.delayed(const Duration(milliseconds: 100));
}

// Send SIGINT to driver
process.kill(ProcessSignal.sigint);

expect(await process.exitCode, 0);
expect(stdoutBuffer.toString(), contains('SIGINT'));
});
});
}