command-stream 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/js/src/$.ansi.mjs +147 -0
  2. package/js/src/$.mjs +49 -6382
  3. package/js/src/$.process-runner-base.mjs +563 -0
  4. package/js/src/$.process-runner-execution.mjs +1497 -0
  5. package/js/src/$.process-runner-orchestration.mjs +250 -0
  6. package/js/src/$.process-runner-pipeline.mjs +1162 -0
  7. package/js/src/$.process-runner-stream-kill.mjs +312 -0
  8. package/js/src/$.process-runner-virtual.mjs +297 -0
  9. package/js/src/$.quote.mjs +161 -0
  10. package/js/src/$.result.mjs +23 -0
  11. package/js/src/$.shell-settings.mjs +84 -0
  12. package/js/src/$.shell.mjs +157 -0
  13. package/js/src/$.state.mjs +401 -0
  14. package/js/src/$.stream-emitter.mjs +111 -0
  15. package/js/src/$.stream-utils.mjs +390 -0
  16. package/js/src/$.trace.mjs +36 -0
  17. package/js/src/$.utils.mjs +2 -23
  18. package/js/src/$.virtual-commands.mjs +113 -0
  19. package/js/src/commands/$.which.mjs +3 -1
  20. package/js/src/commands/index.mjs +24 -0
  21. package/js/src/shell-parser.mjs +125 -83
  22. package/js/tests/resource-cleanup-internals.test.mjs +22 -24
  23. package/js/tests/sigint-cleanup.test.mjs +3 -0
  24. package/package.json +1 -1
  25. package/rust/src/ansi.rs +194 -0
  26. package/rust/src/events.rs +305 -0
  27. package/rust/src/lib.rs +71 -60
  28. package/rust/src/macros.rs +165 -0
  29. package/rust/src/pipeline.rs +411 -0
  30. package/rust/src/quote.rs +161 -0
  31. package/rust/src/state.rs +333 -0
  32. package/rust/src/stream.rs +369 -0
  33. package/rust/src/trace.rs +152 -0
  34. package/rust/src/utils.rs +53 -158
  35. package/rust/tests/events.rs +207 -0
  36. package/rust/tests/macros.rs +77 -0
  37. package/rust/tests/pipeline.rs +93 -0
  38. package/rust/tests/state.rs +207 -0
  39. package/rust/tests/stream.rs +102 -0
@@ -0,0 +1,411 @@
1
+ //! Pipeline execution support
2
+ //!
3
+ //! This module provides pipeline functionality similar to the JavaScript
4
+ //! `$.process-runner-pipeline.mjs` module. It allows chaining commands
5
+ //! together with the output of one command becoming the input of the next.
6
+ //!
7
+ //! ## Usage
8
+ //!
9
+ //! ```rust,no_run
10
+ //! use command_stream::{Pipeline, run};
11
+ //!
12
+ //! #[tokio::main]
13
+ //! async fn main() -> Result<(), Box<dyn std::error::Error>> {
14
+ //! // Create a pipeline
15
+ //! let result = Pipeline::new()
16
+ //! .add("echo hello world")
17
+ //! .add("grep world")
18
+ //! .add("wc -l")
19
+ //! .run()
20
+ //! .await?;
21
+ //!
22
+ //! println!("Output: {}", result.stdout);
23
+ //! Ok(())
24
+ //! }
25
+ //! ```
26
+
27
+ use std::collections::HashMap;
28
+ use std::path::PathBuf;
29
+ use std::process::Stdio;
30
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
31
+ use tokio::process::Command;
32
+
33
+ use crate::trace::trace_lazy;
34
+ use crate::{CommandResult, Result, RunOptions, StdinOption};
35
+
36
+ /// A pipeline of commands to be executed sequentially
37
+ ///
38
+ /// Each command's stdout is piped to the next command's stdin.
39
+ #[derive(Debug, Clone)]
40
+ pub struct Pipeline {
41
+ /// Commands in the pipeline
42
+ commands: Vec<String>,
43
+ /// Initial stdin content (optional)
44
+ stdin: Option<String>,
45
+ /// Working directory
46
+ cwd: Option<PathBuf>,
47
+ /// Environment variables
48
+ env: Option<HashMap<String, String>>,
49
+ /// Whether to mirror output to parent stdout/stderr
50
+ mirror: bool,
51
+ /// Whether to capture output
52
+ capture: bool,
53
+ }
54
+
55
+ impl Default for Pipeline {
56
+ fn default() -> Self {
57
+ Self::new()
58
+ }
59
+ }
60
+
61
+ impl Pipeline {
62
+ /// Create a new empty pipeline
63
+ pub fn new() -> Self {
64
+ Pipeline {
65
+ commands: Vec::new(),
66
+ stdin: None,
67
+ cwd: None,
68
+ env: None,
69
+ mirror: true,
70
+ capture: true,
71
+ }
72
+ }
73
+
74
+ /// Add a command to the pipeline
75
+ pub fn add(mut self, command: impl Into<String>) -> Self {
76
+ self.commands.push(command.into());
77
+ self
78
+ }
79
+
80
+ /// Set the initial stdin content for the first command
81
+ pub fn stdin(mut self, content: impl Into<String>) -> Self {
82
+ self.stdin = Some(content.into());
83
+ self
84
+ }
85
+
86
+ /// Set the working directory for all commands
87
+ pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
88
+ self.cwd = Some(path.into());
89
+ self
90
+ }
91
+
92
+ /// Set environment variables for all commands
93
+ pub fn env(mut self, env: HashMap<String, String>) -> Self {
94
+ self.env = Some(env);
95
+ self
96
+ }
97
+
98
+ /// Set whether to mirror output to stdout/stderr
99
+ pub fn mirror_output(mut self, mirror: bool) -> Self {
100
+ self.mirror = mirror;
101
+ self
102
+ }
103
+
104
+ /// Set whether to capture output
105
+ pub fn capture_output(mut self, capture: bool) -> Self {
106
+ self.capture = capture;
107
+ self
108
+ }
109
+
110
+ /// Execute the pipeline and return the result
111
+ pub async fn run(self) -> Result<CommandResult> {
112
+ if self.commands.is_empty() {
113
+ return Ok(CommandResult {
114
+ stdout: String::new(),
115
+ stderr: "No commands in pipeline".to_string(),
116
+ code: 1,
117
+ });
118
+ }
119
+
120
+ trace_lazy("Pipeline", || {
121
+ format!("Running pipeline with {} commands", self.commands.len())
122
+ });
123
+
124
+ let mut current_stdin = self.stdin.clone();
125
+ let mut last_result = CommandResult {
126
+ stdout: String::new(),
127
+ stderr: String::new(),
128
+ code: 0,
129
+ };
130
+ let mut accumulated_stderr = String::new();
131
+
132
+ for (i, cmd_str) in self.commands.iter().enumerate() {
133
+ let is_last = i == self.commands.len() - 1;
134
+
135
+ trace_lazy("Pipeline", || {
136
+ format!("Executing command {}/{}: {}", i + 1, self.commands.len(), cmd_str)
137
+ });
138
+
139
+ // Check if this is a virtual command
140
+ let first_word = cmd_str.split_whitespace().next().unwrap_or("");
141
+ if crate::commands::are_virtual_commands_enabled() {
142
+ if let Some(result) = self.try_virtual_command(first_word, cmd_str, &current_stdin).await {
143
+ if result.code != 0 {
144
+ return Ok(CommandResult {
145
+ stdout: result.stdout,
146
+ stderr: accumulated_stderr + &result.stderr,
147
+ code: result.code,
148
+ });
149
+ }
150
+ current_stdin = Some(result.stdout.clone());
151
+ accumulated_stderr.push_str(&result.stderr);
152
+ last_result = result;
153
+ continue;
154
+ }
155
+ }
156
+
157
+ // Execute via shell
158
+ let shell = find_available_shell();
159
+ let mut cmd = Command::new(&shell.cmd);
160
+ for arg in &shell.args {
161
+ cmd.arg(arg);
162
+ }
163
+ cmd.arg(cmd_str);
164
+
165
+ // Configure stdio
166
+ cmd.stdin(Stdio::piped());
167
+ cmd.stdout(Stdio::piped());
168
+ cmd.stderr(Stdio::piped());
169
+
170
+ // Set working directory
171
+ if let Some(ref cwd) = self.cwd {
172
+ cmd.current_dir(cwd);
173
+ }
174
+
175
+ // Set environment
176
+ if let Some(ref env_vars) = self.env {
177
+ for (key, value) in env_vars {
178
+ cmd.env(key, value);
179
+ }
180
+ }
181
+
182
+ // Spawn the process
183
+ let mut child = cmd.spawn()?;
184
+
185
+ // Write stdin if available
186
+ if let Some(ref stdin_content) = current_stdin {
187
+ if let Some(mut stdin) = child.stdin.take() {
188
+ let content = stdin_content.clone();
189
+ tokio::spawn(async move {
190
+ let _ = stdin.write_all(content.as_bytes()).await;
191
+ let _ = stdin.shutdown().await;
192
+ });
193
+ }
194
+ }
195
+
196
+ // Read stdout
197
+ let mut stdout_content = String::new();
198
+ if let Some(mut stdout) = child.stdout.take() {
199
+ stdout.read_to_string(&mut stdout_content).await?;
200
+ }
201
+
202
+ // Read stderr
203
+ let mut stderr_content = String::new();
204
+ if let Some(mut stderr) = child.stderr.take() {
205
+ stderr.read_to_string(&mut stderr_content).await?;
206
+ }
207
+
208
+ // Mirror output if enabled and this is the last command
209
+ if is_last && self.mirror {
210
+ if !stdout_content.is_empty() {
211
+ print!("{}", stdout_content);
212
+ }
213
+ if !stderr_content.is_empty() {
214
+ eprint!("{}", stderr_content);
215
+ }
216
+ }
217
+
218
+ // Wait for the process
219
+ let status = child.wait().await?;
220
+ let code = status.code().unwrap_or(-1);
221
+
222
+ accumulated_stderr.push_str(&stderr_content);
223
+
224
+ if code != 0 {
225
+ return Ok(CommandResult {
226
+ stdout: stdout_content,
227
+ stderr: accumulated_stderr,
228
+ code,
229
+ });
230
+ }
231
+
232
+ // Set up stdin for next command
233
+ current_stdin = Some(stdout_content.clone());
234
+ last_result = CommandResult {
235
+ stdout: stdout_content,
236
+ stderr: String::new(),
237
+ code,
238
+ };
239
+ }
240
+
241
+ Ok(CommandResult {
242
+ stdout: last_result.stdout,
243
+ stderr: accumulated_stderr,
244
+ code: last_result.code,
245
+ })
246
+ }
247
+
248
+ /// Try to execute a virtual command
249
+ async fn try_virtual_command(
250
+ &self,
251
+ cmd_name: &str,
252
+ full_cmd: &str,
253
+ stdin: &Option<String>,
254
+ ) -> Option<CommandResult> {
255
+ let parts: Vec<&str> = full_cmd.split_whitespace().collect();
256
+ let args: Vec<String> = parts.iter().skip(1).map(|s| s.to_string()).collect();
257
+
258
+ let ctx = crate::commands::CommandContext {
259
+ args,
260
+ stdin: stdin.clone(),
261
+ cwd: self.cwd.clone(),
262
+ env: self.env.clone(),
263
+ output_tx: None,
264
+ is_cancelled: None,
265
+ };
266
+
267
+ match cmd_name {
268
+ "echo" => Some(crate::commands::echo(ctx).await),
269
+ "pwd" => Some(crate::commands::pwd(ctx).await),
270
+ "cd" => Some(crate::commands::cd(ctx).await),
271
+ "true" => Some(crate::commands::r#true(ctx).await),
272
+ "false" => Some(crate::commands::r#false(ctx).await),
273
+ "sleep" => Some(crate::commands::sleep(ctx).await),
274
+ "cat" => Some(crate::commands::cat(ctx).await),
275
+ "ls" => Some(crate::commands::ls(ctx).await),
276
+ "mkdir" => Some(crate::commands::mkdir(ctx).await),
277
+ "rm" => Some(crate::commands::rm(ctx).await),
278
+ "touch" => Some(crate::commands::touch(ctx).await),
279
+ "cp" => Some(crate::commands::cp(ctx).await),
280
+ "mv" => Some(crate::commands::mv(ctx).await),
281
+ "basename" => Some(crate::commands::basename(ctx).await),
282
+ "dirname" => Some(crate::commands::dirname(ctx).await),
283
+ "env" => Some(crate::commands::env(ctx).await),
284
+ "exit" => Some(crate::commands::exit(ctx).await),
285
+ "which" => Some(crate::commands::which(ctx).await),
286
+ "yes" => Some(crate::commands::yes(ctx).await),
287
+ "seq" => Some(crate::commands::seq(ctx).await),
288
+ "test" => Some(crate::commands::test(ctx).await),
289
+ _ => None,
290
+ }
291
+ }
292
+ }
293
+
294
+ /// Shell configuration
295
+ #[derive(Debug, Clone)]
296
+ struct ShellConfig {
297
+ cmd: String,
298
+ args: Vec<String>,
299
+ }
300
+
301
+ /// Find an available shell
302
+ fn find_available_shell() -> ShellConfig {
303
+ let is_windows = cfg!(windows);
304
+
305
+ if is_windows {
306
+ ShellConfig {
307
+ cmd: "cmd.exe".to_string(),
308
+ args: vec!["/c".to_string()],
309
+ }
310
+ } else {
311
+ let shells = [
312
+ ("/bin/sh", "-c"),
313
+ ("/usr/bin/sh", "-c"),
314
+ ("/bin/bash", "-c"),
315
+ ];
316
+
317
+ for (cmd, arg) in shells {
318
+ if std::path::Path::new(cmd).exists() {
319
+ return ShellConfig {
320
+ cmd: cmd.to_string(),
321
+ args: vec![arg.to_string()],
322
+ };
323
+ }
324
+ }
325
+
326
+ ShellConfig {
327
+ cmd: "/bin/sh".to_string(),
328
+ args: vec!["-c".to_string()],
329
+ }
330
+ }
331
+ }
332
+
333
+ /// Extension trait to add `.pipe()` method to ProcessRunner
334
+ pub trait PipelineExt {
335
+ /// Pipe the output of this command to another command
336
+ fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
337
+ }
338
+
339
+ impl PipelineExt for crate::ProcessRunner {
340
+ fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
341
+ PipelineBuilder {
342
+ first: self,
343
+ additional: vec![command.into()],
344
+ }
345
+ }
346
+ }
347
+
348
+ /// Builder for piping commands together
349
+ pub struct PipelineBuilder {
350
+ first: crate::ProcessRunner,
351
+ additional: Vec<String>,
352
+ }
353
+
354
+ impl PipelineBuilder {
355
+ /// Add another command to the pipeline
356
+ pub fn pipe(mut self, command: impl Into<String>) -> Self {
357
+ self.additional.push(command.into());
358
+ self
359
+ }
360
+
361
+ /// Execute the pipeline
362
+ pub async fn run(mut self) -> Result<CommandResult> {
363
+ // First, run the initial command
364
+ let first_result = self.first.run().await?;
365
+
366
+ if first_result.code != 0 {
367
+ return Ok(first_result);
368
+ }
369
+
370
+ // Then run the rest as a pipeline
371
+ let mut current_stdin = Some(first_result.stdout);
372
+ let mut accumulated_stderr = first_result.stderr;
373
+ let mut last_result = CommandResult {
374
+ stdout: String::new(),
375
+ stderr: String::new(),
376
+ code: 0,
377
+ };
378
+
379
+ for cmd_str in &self.additional {
380
+ let mut runner = crate::ProcessRunner::new(
381
+ cmd_str.clone(),
382
+ RunOptions {
383
+ stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
384
+ mirror: false,
385
+ capture: true,
386
+ ..Default::default()
387
+ },
388
+ );
389
+
390
+ let result = runner.run().await?;
391
+ accumulated_stderr.push_str(&result.stderr);
392
+
393
+ if result.code != 0 {
394
+ return Ok(CommandResult {
395
+ stdout: result.stdout,
396
+ stderr: accumulated_stderr,
397
+ code: result.code,
398
+ });
399
+ }
400
+
401
+ current_stdin = Some(result.stdout.clone());
402
+ last_result = result;
403
+ }
404
+
405
+ Ok(CommandResult {
406
+ stdout: last_result.stdout,
407
+ stderr: accumulated_stderr,
408
+ code: last_result.code,
409
+ })
410
+ }
411
+ }
@@ -0,0 +1,161 @@
1
+ //! Shell quoting utilities for command-stream
2
+ //!
3
+ //! This module provides functions for safely quoting values for shell usage,
4
+ //! preventing command injection and ensuring proper argument handling.
5
+
6
+ /// Quote a value for safe shell usage
7
+ ///
8
+ /// This function quotes strings appropriately for use in shell commands,
9
+ /// handling special characters and edge cases.
10
+ ///
11
+ /// # Examples
12
+ ///
13
+ /// ```
14
+ /// use command_stream::quote::quote;
15
+ ///
16
+ /// // Safe characters are passed through unchanged
17
+ /// assert_eq!(quote("hello"), "hello");
18
+ /// assert_eq!(quote("/path/to/file"), "/path/to/file");
19
+ ///
20
+ /// // Special characters are quoted
21
+ /// assert_eq!(quote("hello world"), "'hello world'");
22
+ ///
23
+ /// // Single quotes in strings are escaped
24
+ /// assert_eq!(quote("it's"), "'it'\\''s'");
25
+ ///
26
+ /// // Empty strings are quoted
27
+ /// assert_eq!(quote(""), "''");
28
+ /// ```
29
+ pub fn quote(value: &str) -> String {
30
+ if value.is_empty() {
31
+ return "''".to_string();
32
+ }
33
+
34
+ // If already properly quoted with single quotes, check if we can use as-is
35
+ if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
36
+ let inner = &value[1..value.len() - 1];
37
+ if !inner.contains('\'') {
38
+ return value.to_string();
39
+ }
40
+ }
41
+
42
+ // If already double-quoted, wrap in single quotes
43
+ if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
44
+ return format!("'{}'", value);
45
+ }
46
+
47
+ // Check if the string needs quoting at all
48
+ // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus, at
49
+ let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
50
+
51
+ if safe_pattern.is_match(value) {
52
+ return value.to_string();
53
+ }
54
+
55
+ // Default behavior: wrap in single quotes and escape any internal single quotes
56
+ // The shell escape sequence for a single quote inside single quotes is: '\''
57
+ // This ends the single quote, adds an escaped single quote, and starts single quotes again
58
+ format!("'{}'", value.replace('\'', "'\\''"))
59
+ }
60
+
61
+ /// Quote multiple values and join them with spaces
62
+ ///
63
+ /// Convenience function for quoting a list of arguments.
64
+ ///
65
+ /// # Examples
66
+ ///
67
+ /// ```
68
+ /// use command_stream::quote::quote_all;
69
+ ///
70
+ /// let args = vec!["echo", "hello world", "test"];
71
+ /// assert_eq!(quote_all(&args), "echo 'hello world' test");
72
+ /// ```
73
+ pub fn quote_all(values: &[&str]) -> String {
74
+ values
75
+ .iter()
76
+ .map(|v| quote(v))
77
+ .collect::<Vec<_>>()
78
+ .join(" ")
79
+ }
80
+
81
+ /// Check if a string needs quoting for shell usage
82
+ ///
83
+ /// Returns true if the string contains characters that would be interpreted
84
+ /// specially by the shell.
85
+ ///
86
+ /// # Examples
87
+ ///
88
+ /// ```
89
+ /// use command_stream::quote::needs_quoting;
90
+ ///
91
+ /// assert!(!needs_quoting("hello"));
92
+ /// assert!(needs_quoting("hello world"));
93
+ /// assert!(needs_quoting("$PATH"));
94
+ /// ```
95
+ pub fn needs_quoting(value: &str) -> bool {
96
+ if value.is_empty() {
97
+ return true;
98
+ }
99
+
100
+ let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
101
+ !safe_pattern.is_match(value)
102
+ }
103
+
104
+ #[cfg(test)]
105
+ mod tests {
106
+ use super::*;
107
+
108
+ #[test]
109
+ fn test_quote_empty() {
110
+ assert_eq!(quote(""), "''");
111
+ }
112
+
113
+ #[test]
114
+ fn test_quote_safe_chars() {
115
+ assert_eq!(quote("hello"), "hello");
116
+ assert_eq!(quote("/path/to/file"), "/path/to/file");
117
+ assert_eq!(quote("file.txt"), "file.txt");
118
+ assert_eq!(quote("key=value"), "key=value");
119
+ assert_eq!(quote("user@host"), "user@host");
120
+ }
121
+
122
+ #[test]
123
+ fn test_quote_special_chars() {
124
+ assert_eq!(quote("hello world"), "'hello world'");
125
+ assert_eq!(quote("it's"), "'it'\\''s'");
126
+ assert_eq!(quote("$var"), "'$var'");
127
+ assert_eq!(quote("test*"), "'test*'");
128
+ }
129
+
130
+ #[test]
131
+ fn test_quote_already_quoted() {
132
+ assert_eq!(quote("'already quoted'"), "'already quoted'");
133
+ assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
134
+ }
135
+
136
+ #[test]
137
+ fn test_quote_all() {
138
+ let args = vec!["echo", "hello world", "test"];
139
+ assert_eq!(quote_all(&args), "echo 'hello world' test");
140
+ }
141
+
142
+ #[test]
143
+ fn test_needs_quoting() {
144
+ assert!(!needs_quoting("hello"));
145
+ assert!(!needs_quoting("/path/to/file"));
146
+ assert!(needs_quoting("hello world"));
147
+ assert!(needs_quoting("$PATH"));
148
+ assert!(needs_quoting(""));
149
+ assert!(needs_quoting("test*"));
150
+ }
151
+
152
+ #[test]
153
+ fn test_quote_with_newlines() {
154
+ assert_eq!(quote("line1\nline2"), "'line1\nline2'");
155
+ }
156
+
157
+ #[test]
158
+ fn test_quote_with_tabs() {
159
+ assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
160
+ }
161
+ }