Skip to content

Conversation

@nielsenko
Copy link
Contributor

@nielsenko nielsenko commented Dec 11, 2025

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.

Summary by CodeRabbit

  • New Features

    • Added a command execution utility with stdin/stdout/stderr forwarding, signal propagation to child processes, and optional working directory.
  • Exports

    • Exposed the execution utility via the package public API.
  • Tests

    • Added a test driver and comprehensive tests covering success, error handling, exit codes, and signal propagation (Unix-specific scenarios).
  • Chores

    • Added a runtime dependency required by the new utility.

✏️ Tip: You can customize this high-level summary in your review settings.

@nielsenko nielsenko self-assigned this Dec 11, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 11, 2025

📝 Walkthrough

Walkthrough

Adds a top-level re-export and a new execute(...) utility that spawns a platform shell, runs a command, forwards stdin/stdout/stderr and termination signals to the child, and returns the child's exit code. Adds an async dependency and tests with a driver (some tests are Unix-only).

Changes

Cohort / File(s) Summary
Public export
packages/cli_tools/lib/execute.dart
Adds export 'src/execute/execute.dart'; to expose the nested execute implementation.
Core implementation
packages/cli_tools/lib/src/execute/execute.dart
New Future<int> execute(String command, {Stream<List<int>>? stdin, IOSink? stdout, IOSink? stderr, Directory? workingDirectory}) that spawns a platform-appropriate shell (bash or cmd.exe), pipes stdin/stdout/stderr, manages subscriptions and stdin lifecycle, forwards SIGINT (and SIGTERM on non-Windows) to the child, and returns the exit code.
Dependency
packages/cli_tools/pubspec.yaml
Adds dependency async: ^2.10.0.
Tests & driver
packages/cli_tools/test/execute_driver.dart, packages/cli_tools/test/execute_test.dart
Adds test driver (main(List<String> args)) that runs execute() and exits with its code; tests compile/run the driver to verify successful execution, non-zero exits, missing commands, stdout/stderr capture, and SIGINT forwarding. Some tests are skipped on Windows.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Caller as Test / Caller
participant Exec as execute()
participant Shell as Shell (bash/cmd.exe)
participant Child as Child process
participant IO as OS I/O

Caller->>Exec: call execute(command, stdin?, stdout?, stderr?, workingDirectory?)
Exec->>Shell: spawn platform shell with command (bash -c / cmd.exe /c)
Shell->>Child: shell evaluates and starts command
Exec->>Child: pipe provided stdin -> child's stdin
Child->>IO: emit stdout/stderr bytes
IO->>Exec: receive bytes
Exec->>Caller: relay bytes to provided stdout/stderr sinks
Note over Exec,Child: On SIGINT (and SIGTERM where applicable)
Exec->>Child: forward signal
Note over Exec: when stdin stream completes
Exec->>Child: close child's stdin
Child-->>Shell: exit with code
Shell-->>Exec: return exit code
Exec-->>Caller: return exit code

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Inspect stream subscription/cancellation and stdin close sequencing in packages/cli_tools/lib/src/execute/execute.dart for leaks/race conditions.
  • Verify signal-forwarding logic and platform branching (Windows vs Unix) for correctness.
  • Review tests in packages/cli_tools/test/execute_test.dart for flakiness, proper skips on Windows, and correct compile/teardown of the driver.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and specifically describes the main change: adding an execute helper function to run shell commands, which directly aligns with the primary additions of the function and its supporting infrastructure.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
packages/cli_tools/lib/src/execute/execute.dart (1)

52-58: Potential issue: stdin may not complete before process exits.

If the child process exits before stdin completes, stdinSubscription?.cancel() is called after process.stdin.close(). This ordering is fine, but if the stdin stream is infinite or long-running, the process could exit while stdin is still being forwarded. Consider cancelling the stdin subscription before closing process.stdin to ensure clean teardown.

   await [
     stdout.addStream(process.stdout),
     stderr.addStream(process.stderr),
   ].wait;
+  await stdinSubscription?.cancel();
   await process.stdin.close();
-  await stdinSubscription?.cancel();
   await sigSubscription.cancel();
packages/cli_tools/test/execute_test.dart (1)

51-51: Error message assertion may be fragile across systems.

The assertion contains('not found') assumes a specific shell error message format. Different bash versions or locales may produce different messages (e.g., "command not found", "not found", or localized equivalents).

Consider relaxing to just check for non-zero exit code, or use a case-insensitive/broader pattern:

-        expect(result.stderr, contains('not found'));
+        expect(result.stderr.toLowerCase(), contains('not found'));
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6a6e104 and 0c1243c.

📒 Files selected for processing (5)
  • packages/cli_tools/lib/execute.dart (1 hunks)
  • packages/cli_tools/lib/src/execute/execute.dart (1 hunks)
  • packages/cli_tools/pubspec.yaml (1 hunks)
  • packages/cli_tools/test/execute_driver.dart (1 hunks)
  • packages/cli_tools/test/execute_test.dart (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-07T07:55:17.269Z
Learnt from: christerswahn
Repo: serverpod/cli_tools PR: 57
File: packages/config/test/better_command_runner/default_flags_test.dart:1-1
Timestamp: 2025-08-07T07:55:17.269Z
Learning: In the `config` package, `better_command_runner.dart` is intentionally kept as a separate import (`package:config/better_command_runner.dart`) rather than being re-exported through the main `packages/config/lib/config.dart` barrel file. This separation is by design according to the package maintainer christerswahn.

Applied to files:

  • packages/cli_tools/lib/execute.dart
📚 Learning: 2025-06-12T14:55:38.006Z
Learnt from: christerswahn
Repo: serverpod/cli_tools PR: 47
File: lib/src/config/options.dart:552-567
Timestamp: 2025-06-12T14:55:38.006Z
Learning: The codebase relies on a recent version of `package:args` where `ArgParser.addFlag` accepts the `hideNegatedUsage` parameter.

Applied to files:

  • packages/cli_tools/pubspec.yaml
🔇 Additional comments (4)
packages/cli_tools/lib/execute.dart (1)

1-1: LGTM!

Clean re-export following Dart package conventions for exposing the public API.

packages/cli_tools/test/execute_driver.dart (1)

5-5: LGTM with caveat on argument handling.

The driver correctly invokes execute and propagates the exit code. Note that args.join(' ') will not preserve quoting for arguments containing spaces, but this is acceptable for the test use case where commands are passed as single-string arguments.

packages/cli_tools/test/execute_test.dart (1)

19-19: Top-level Future eagerly executes compileDriver().

The _exe future is created at import time, meaning the driver is compiled before any test runs. This is likely intentional for efficiency but worth noting—if compilation fails, all tests will fail during setup rather than with a clear test failure message.

packages/cli_tools/pubspec.yaml (1)

19-19: LGTM!

The async package is a well-maintained official Dart package, and StreamGroup.merge is appropriately used in the execute implementation for merging signal streams. The constraint ^2.10.0 is compatible with the SDK requirement of ^3.3.0.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/cli_tools/test/execute_driver.dart (1)

5-5: Slightly more idiomatic main and note on argument joining

Functionally this is fine for a test driver. If you want to align with typical Dart style and make behavior a bit clearer, you could:

  • Declare main as Future<void> instead of void with async.
  • Consider documenting that args are rejoined with a single space and that this is only meant to receive a single command line string (so callers should pass the command already assembled as one argument).

Example:

-import 'package:cli_tools/execute.dart';
-
-void main(final List<String> args) async => exit(await execute(args.join(' ')));
+import 'package:cli_tools/execute.dart';
+
+Future<void> main(final List<String> args) async {
+  // Expects the command as a single argument; tests pass it pre‑assembled.
+  final command = args.join(' ');
+  exit(await execute(command));
+}
packages/cli_tools/lib/src/execute/execute.dart (1)

33-59: Ensure signal & stdin subscriptions are always cleaned up (use try/finally)

Right now, if streaming to stdout/stderr throws (e.g., broken pipe), the await [].wait will error and the subsequent cleanup (stdinSubscription?.cancel(), process.stdin.close(), sigSubscription.cancel()) is skipped. That can leave the signal subscription and the child process stdin open.

Wrapping the streaming section in a try/finally keeps behavior the same on success but guarantees cleanup on error:

   // 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;
+  try {
+    await [
+      stdout.addStream(process.stdout),
+      stderr.addStream(process.stderr),
+    ].wait;
+  } finally {
+    await stdinSubscription?.cancel();
+    await process.stdin.close();
+    await sigSubscription.cancel();
+  }
+
+  return await process.exitCode;

This makes execute more robust in error conditions (e.g., when the parent’s stdout/stderr are no longer writable) without changing normal-path behavior.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c1243c and ae91cb8.

📒 Files selected for processing (5)
  • packages/cli_tools/lib/execute.dart (1 hunks)
  • packages/cli_tools/lib/src/execute/execute.dart (1 hunks)
  • packages/cli_tools/pubspec.yaml (1 hunks)
  • packages/cli_tools/test/execute_driver.dart (1 hunks)
  • packages/cli_tools/test/execute_test.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/cli_tools/lib/execute.dart
  • packages/cli_tools/pubspec.yaml
  • packages/cli_tools/test/execute_test.dart

Copy link
Collaborator

@christerswahn christerswahn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice extension! Had two comments!

@nielsenko
Copy link
Contributor Author

This was extracted from serverpod/serverpod#4416 for common use

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/cli_tools/lib/src/execute/execute.dart (1)

1-2: Consider consolidating the duplicate dart:io import.

The library is imported twice (with and without an alias). You can simplify by removing one import and using a consistent approach.

Option 1: Remove the aliased import and use unqualified references:

 import 'dart:io';
-import 'dart:io' as io;

 import 'package:async/async.dart';

Then update lines 29-30:

-  stdout ??= io.stdout;
-  stderr ??= io.stderr;
+  stdout ??= stdout;
+  stderr ??= stderr;

Wait, that won't work because of the parameter shadowing.

Option 2 (preferred): Keep only the aliased import:

-import 'dart:io';
 import 'dart:io' as io;

And ensure all dart:io references use the io. prefix for consistency.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ae91cb8 and 9ead208.

📒 Files selected for processing (1)
  • packages/cli_tools/lib/src/execute/execute.dart (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-06-12T14:55:38.006Z
Learnt from: christerswahn
Repo: serverpod/cli_tools PR: 47
File: lib/src/config/options.dart:552-567
Timestamp: 2025-06-12T14:55:38.006Z
Learning: For this project, avoid review comments that simply repeat compile-time errors the Dart analyzer already reports; focus on issues not caught by the analyzer.

Applied to files:

  • packages/cli_tools/lib/src/execute/execute.dart
📚 Learning: 2025-10-10T08:50:09.902Z
Learnt from: indraneel12
Repo: serverpod/cli_tools PR: 0
File: :0-0
Timestamp: 2025-10-10T08:50:09.902Z
Learning: PR #71 in serverpod/cli_tools adds support for displaying OptionGroup names in CLI usage output. The implementation does NOT include fallback group names or numbered defaults for anonymous groups - it only displays the group.name when present and shows groupless options first without any header.

Applied to files:

  • packages/cli_tools/lib/src/execute/execute.dart
🔇 Additional comments (5)
packages/cli_tools/lib/src/execute/execute.dart (5)

22-28: Function signature is well-designed.

The signature provides good flexibility with optional parameters for stdin, stdout, stderr, and working directory while keeping the command parameter required.


29-34: Platform-appropriate shell selection.

The default parameter handling and shell selection logic correctly distinguishes between Windows (cmd) and Unix-like systems (bash).


36-43: Well-justified shell invocation approach.

The comment clearly explains why invoking the shell explicitly is preferred over runInShell: true to avoid edge cases with quoting and spaces. The implementation is correct.


45-53: Signal forwarding correctly handles Windows platform differences.

The implementation properly guards ProcessSignal.sigterm with a Windows check, preventing runtime exceptions on Windows while still forwarding SIGINT on all platforms. This addresses the earlier review feedback.


55-72: Resource management and cleanup are handled correctly.

The implementation properly:

  • Forwards stdin with appropriate error handling
  • Streams stdout/stderr in parallel using .wait
  • Cleans up all subscriptions and streams in the correct order
  • Returns the child process exit code

The sequence ensures that output is fully consumed before closing streams and cancelling signal handlers.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/cli_tools/lib/src/execute/execute.dart (1)

57-62: Consider logging stdin stream errors for debugging.

The empty onError handler silently swallows any errors from the stdin stream. While errors are unlikely, having at least debug-level logging would make troubleshooting easier if stdin forwarding issues occur.

For example:

final stdinSubscription = stdin?.listen(
  process.stdin.add,
  cancelOnError: true,
  onError: (final error, final stackTrace) {
    // Log or report the error for debugging purposes
    stderr?.writeln('Warning: stdin stream error: $error');
  },
);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ead208 and 749b129.

📒 Files selected for processing (1)
  • packages/cli_tools/lib/src/execute/execute.dart (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-06-12T14:55:38.006Z
Learnt from: christerswahn
Repo: serverpod/cli_tools PR: 47
File: lib/src/config/options.dart:552-567
Timestamp: 2025-06-12T14:55:38.006Z
Learning: For this project, avoid review comments that simply repeat compile-time errors the Dart analyzer already reports; focus on issues not caught by the analyzer.

Applied to files:

  • packages/cli_tools/lib/src/execute/execute.dart
📚 Learning: 2025-10-10T08:50:09.902Z
Learnt from: indraneel12
Repo: serverpod/cli_tools PR: 0
File: :0-0
Timestamp: 2025-10-10T08:50:09.902Z
Learning: PR #71 in serverpod/cli_tools adds support for displaying OptionGroup names in CLI usage output. The implementation does NOT include fallback group names or numbered defaults for anonymous groups - it only displays the group.name when present and shows groupless options first without any header.

Applied to files:

  • packages/cli_tools/lib/src/execute/execute.dart
🔇 Additional comments (8)
packages/cli_tools/lib/src/execute/execute.dart (8)

1-4: LGTM!

The imports are appropriate. The io namespace alias helps distinguish local parameters from global stdio, and the async package is correctly used for stream merging.


6-23: LGTM!

The documentation is comprehensive and clearly explains the function's behavior, including signal forwarding, stream handling, and the fact that it runs shell commands.


24-30: LGTM!

The function signature is well-designed with appropriate optional parameters for customization while maintaining sensible defaults.


31-33: LGTM!

The default values are appropriate and correctly use the io namespace to access global stdio streams.


35-36: LGTM!

The platform-specific shell selection is correct (cmd /c on Windows, bash -c on Unix-like systems).


38-45: LGTM!

The process start logic is correct. The comment helpfully explains why invoking the shell directly is preferred over runInShell: true to handle quoting and spacing edge cases.


47-55: LGTM!

The signal forwarding logic correctly handles platform differences by conditionally including SIGTERM only on non-Windows platforms. The Windows SIGTERM issue from previous reviews has been properly addressed.


64-73: LGTM!

The output streaming and cleanup sequence is correct:

  1. Waits for all child output to be captured
  2. Cancels stdin subscription
  3. Closes child stdin to signal EOF
  4. Cancels signal subscription
  5. Returns the exit code

This ensures proper resource cleanup and all output is captured before returning.

Copy link
Collaborator

@christerswahn christerswahn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Remember to include scope in the PR, title:
feat(cli_tools)

That will enable the changelog and version scripts to work correctly.

@christerswahn christerswahn changed the title feat: Add execute helper function to run shell commands feat(cli_tools): Add execute helper function to run shell commands Dec 12, 2025
@nielsenko nielsenko merged commit c16035f into serverpod:main Dec 12, 2025
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants