Skip to content

Commit c16035f

Browse files
authored
feat(cli_tools): Add execute helper function to run shell commands (#96)
Add `execute` helper for running shell commands with proper signal forwarding, stdin passthrough, and real-time output streaming - unlike `Process.run` which buffers output and doesn't forward signals.
1 parent 6a6e104 commit c16035f

5 files changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export 'src/execute/execute.dart';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import 'dart:io';
2+
import 'dart:io' as io; // to distinguish stdout from io.stdout, etc.
3+
4+
import 'package:async/async.dart';
5+
6+
/// Executes a [command] in a child process shell and returns the exit code.
7+
///
8+
/// The [command] is a shell command line that can include arguments, e.g.,
9+
/// `echo "Hello world!"`.
10+
///
11+
/// Child stdout/stderr will be forwarded to the parent process. It will use the
12+
/// parent's defaults for [stdin]/[stdout] unless overridden with alternative
13+
/// [IOSink]s.
14+
///
15+
/// Parent signals (SIGINT & SIGTERM) will be forwarded to the child, while
16+
/// [command] is running
17+
///
18+
/// If you pass a [stdin] stream then it will be consumed and forwarded to the
19+
/// child. If you plan on listening to stdin again later, make sure to convert
20+
/// it from a single subscription stream first.
21+
///
22+
/// You can specify what [workingDirectory] the child process should be spawned
23+
/// in. It will default to [Directory.current].
24+
Future<int> execute(
25+
final String command, {
26+
final Stream<List<int>>? stdin,
27+
IOSink? stdout,
28+
IOSink? stderr,
29+
Directory? workingDirectory,
30+
}) async {
31+
stdout ??= io.stdout;
32+
stderr ??= io.stderr;
33+
workingDirectory ??= Directory.current;
34+
35+
final shell = Platform.isWindows ? 'cmd' : 'bash';
36+
final shellArg = Platform.isWindows ? '/c' : '-c';
37+
38+
// NOTE: We invoke a shell instead of the command directly (with runInShell:
39+
// true). This avoid a lot of edge cases regarding quoting, repeated spaces,
40+
// etc.
41+
final process = await Process.start(
42+
shell,
43+
[shellArg, command],
44+
workingDirectory: workingDirectory.path,
45+
);
46+
47+
// Forward signals to child process
48+
final sigSubscription = StreamGroup.merge(
49+
[
50+
ProcessSignal.sigint,
51+
if (!Platform.isWindows) ProcessSignal.sigterm,
52+
].map((final s) => s.watch()),
53+
).listen((final s) {
54+
process.kill(s);
55+
});
56+
57+
// Forward stdin to the child process
58+
final stdinSubscription = stdin?.listen(
59+
process.stdin.add,
60+
cancelOnError: true,
61+
onError: (final _) {}, // extremely unlikely, but why not
62+
);
63+
64+
// Stream output directly to terminal
65+
await [
66+
stdout.addStream(process.stdout),
67+
stderr.addStream(process.stderr),
68+
].wait;
69+
await stdinSubscription?.cancel();
70+
await process.stdin.close();
71+
await sigSubscription.cancel();
72+
73+
return await process.exitCode;
74+
}

packages/cli_tools/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ environment:
1616

1717
dependencies:
1818
args: ^2.7.0
19+
async: ^2.10.0
1920
ci: ^0.1.0
2021
config: ^0.8.3
2122
http: '>=0.13.0 <2.0.0'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import 'dart:io';
2+
3+
import 'package:cli_tools/execute.dart';
4+
5+
void main(final List<String> args) async => exit(await execute(args.join(' ')));
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// These test depends on bash and unix specific tools (trap, exit, echo)
2+
@TestOn('!windows')
3+
library;
4+
5+
import 'dart:io';
6+
7+
import 'package:cli_tools/execute.dart';
8+
import 'package:path/path.dart' as p;
9+
import 'package:test/test.dart';
10+
11+
Future<String> compileDriver() async {
12+
final driverExe = p.join(Directory.systemTemp.path, 'execute_driver.exe');
13+
final result = await Process.run(
14+
'dart', ['compile', 'exe', 'test/execute_driver.dart', '-o', driverExe]);
15+
if (result.exitCode != 0) throw StateError('Failed to compile driver');
16+
return driverExe;
17+
}
18+
19+
Future<String> _exe = compileDriver();
20+
21+
Future<ProcessResult> runDriver(final String command) async =>
22+
await Process.run(await _exe, [command]);
23+
24+
Future<Process> startDriver(final String command) async =>
25+
await Process.start(await _exe, [command]);
26+
27+
void main() {
28+
group('Given execute', () {
29+
test(
30+
'when running a command that succeeds, then the effect is expected',
31+
() async {
32+
final result = await runDriver('echo "Hello world!"');
33+
expect(result.exitCode, 0);
34+
expect(result.stdout, contains('Hello world!'));
35+
},
36+
);
37+
38+
test(
39+
'when running a command that fails, then the exit code is propagated',
40+
() async {
41+
expect(await execute('exit 42'), 42);
42+
},
43+
);
44+
45+
test(
46+
'when running a non-existent command, then an error happens',
47+
() async {
48+
final result = await runDriver('fhasjkhfs');
49+
expect(result.exitCode, isNot(0));
50+
expect(result.stderr, contains('not found'));
51+
},
52+
);
53+
54+
test('when sending SIGINT, then it is forwarded to the child process',
55+
() async {
56+
// Use trap to catch signal in child
57+
final process = await startDriver(
58+
'trap "echo SIGINT; exit 0" INT; echo "Running"; while :; do sleep 0.1; done');
59+
60+
// Collect stdout incrementally
61+
final stdoutBuffer = StringBuffer();
62+
process.stdout.transform(systemEncoding.decoder).listen((final data) {
63+
stdoutBuffer.write(data);
64+
});
65+
66+
// Wait for the script to start (look for "Running" message)
67+
while (!stdoutBuffer.toString().contains('Running')) {
68+
await Future<void>.delayed(const Duration(milliseconds: 100));
69+
}
70+
71+
// Send SIGINT to driver
72+
process.kill(ProcessSignal.sigint);
73+
74+
expect(await process.exitCode, 0);
75+
expect(stdoutBuffer.toString(), contains('SIGINT'));
76+
});
77+
});
78+
}

0 commit comments

Comments
 (0)