Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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';
72 changes: 72 additions & 0 deletions packages/cli_tools/lib/src/execute/execute.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'dart:io';
import 'dart:io' as io;

import 'package:async/async.dart';

/// Executes a [command] in a child process shell and returns the exit code. The
/// [command] can include arguements, fx: `echo "Hello world!"`.
///
/// Child stdout/stderr will be forwarded to the parent process. It will use the
/// parents defaults for [stdin]/[stdout] unless overriden 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].
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Future<int> execute(
final String command, {
Comment thread
christerswahn marked this conversation as resolved.
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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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),
Comment thread
christerswahn marked this conversation as resolved.
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));
}
Comment thread
nielsenko marked this conversation as resolved.

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

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