pitchfork-cli 2.29.0

Daemons with DX
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Shell abstraction for cross-platform command execution
//!
//! This module provides a platform-agnostic way to execute shell commands,
//! supporting different shells on Unix and Windows platforms.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Supported shell types for command execution
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
#[allow(clippy::enum_variant_names)] // PowerShell is the correct name for this shell
pub enum Shell {
    /// POSIX-compatible shell (default on Unix)
    #[default]
    Sh,
    /// Bash shell
    Bash,
    /// Zsh shell
    Zsh,
    /// Fish shell
    Fish,
    /// Windows Command Prompt
    Cmd,
    /// PowerShell (cross-platform)
    #[serde(alias = "pwsh")]
    PowerShell,
}

impl Shell {
    /// Returns the default shell for the current platform
    #[cfg(unix)]
    pub fn default_for_platform() -> Self {
        Shell::Sh
    }

    /// Returns the default shell for the current platform
    #[cfg(windows)]
    pub fn default_for_platform() -> Self {
        Shell::Cmd
    }

    /// Returns the shell program name/path
    pub fn program(&self) -> &'static str {
        match self {
            Shell::Sh => "sh",
            Shell::Bash => "bash",
            Shell::Zsh => "zsh",
            Shell::Fish => "fish",
            Shell::Cmd => "cmd",
            Shell::PowerShell => {
                // pwsh is the cross-platform PowerShell, powershell is Windows-only
                #[cfg(windows)]
                {
                    "powershell"
                }
                #[cfg(not(windows))]
                {
                    "pwsh"
                }
            }
        }
    }

    /// Returns the arguments needed to execute a command string
    pub fn exec_args(&self, command: &str) -> Vec<String> {
        match self {
            Shell::Sh | Shell::Bash | Shell::Zsh => {
                vec!["-c".to_string(), command.to_string()]
            }
            Shell::Fish => {
                vec!["-c".to_string(), command.to_string()]
            }
            Shell::Cmd => {
                vec!["/C".to_string(), command.to_string()]
            }
            Shell::PowerShell => {
                vec!["-Command".to_string(), command.to_string()]
            }
        }
    }

    /// Creates a tokio Command configured to run the given command string
    pub fn command(&self, cmd: &str) -> tokio::process::Command {
        let mut command = tokio::process::Command::new(self.program());
        command.shell_script(self.program(), &self.exec_options(), cmd);
        command
    }

    /// Creates a std Command configured to run the given command string
    #[allow(dead_code)] // Available for future use (e.g., spawn commands)
    pub fn std_command(&self, cmd: &str) -> std::process::Command {
        let mut command = std::process::Command::new(self.program());
        command.shell_script(self.program(), &self.exec_options(), cmd);
        command
    }

    /// The options that come before the command string: `exec_args` without it.
    fn exec_options(&self) -> Vec<String> {
        let mut args = self.exec_args("");
        args.pop();
        args
    }
}

/// Prevents a spawned command from creating a console window on Windows.
///
/// The supervisor is created with `DETACHED_PROCESS | CREATE_NO_WINDOW`, so it
/// has no console of its own. On Windows the loader gives every
/// console-subsystem child of a console-less parent a brand new *visible*
/// console. Redirecting the child's stdio to pipes or NUL does not suppress
/// that, because the allocation is decided from the PE subsystem and the
/// creation flags rather than from the handles, so anything spawned from
/// inside the supervisor has to opt out explicitly.
///
/// Opting out does not leave the child without a console: `CREATE_NO_WINDOW`
/// gives it one of its own that simply has no window, so console APIs keep
/// working. Measured on Windows 11 — a child spawned with the flag reports
/// `GetConsoleCP() = 932` and `GetConsoleProcessList() = 1`, both of which fail
/// for a process with no console. What changes is only that the console is not
/// drawn, and that `GetConsoleWindow` returns null for it.
///
/// Implemented for both `std::process::Command` and `tokio::process::Command`,
/// and returns `&mut Self` so it drops into the existing fluent chains. The
/// non-Windows impls are no-ops, which keeps the call sites free of `cfg`.
///
/// The flag is only applied when this process has no console, because that is
/// the only case where a child would get one of its own. See
/// `child_would_get_its_own_console`.
///
/// Note: `creation_flags` *replaces* a command's creation flags rather than
/// OR-ing into them. Call this once per command, and after any other
/// `creation_flags` call, or those flags are silently dropped.
pub(crate) trait HideConsoleWindow {
    fn hide_console_window(&mut self) -> &mut Self;
}

/// Whether a console-subsystem child of this process would be given a console
/// of its own rather than inheriting one.
///
/// A child inherits the parent's console whenever the parent has one, and no
/// new window appears, so `CREATE_NO_WINDOW` is unnecessary there. It would
/// also be a behaviour change: the child would be put on a separate console
/// instead of the shared one, so a console control event sent to the parent's
/// console would no longer reach it. Detached processes such as the background
/// supervisor have no console, and only there does a child get a new — and
/// visible — one.
///
/// `GetConsoleWindow` reports the absence of a console *window*, which is not
/// quite the same as the absence of a console: it also returns null for a
/// console that has no window, such as a ConPTY session or a process started
/// with `CREATE_NO_WINDOW` itself. Those cases are counted as "no console"
/// here, and that costs nothing — the child is then given a console of its own
/// instead of sharing a console nobody can see, which is what every one of
/// these spawns did unconditionally before this check existed. What the check
/// is for is the case it does detect precisely: a supervisor running in the
/// foreground on a real console, whose children should keep sharing it.
#[cfg(windows)]
fn child_would_get_its_own_console() -> bool {
    let console = unsafe { windows_sys::Win32::System::Console::GetConsoleWindow() };
    console.is_null()
}

#[cfg(windows)]
impl HideConsoleWindow for std::process::Command {
    fn hide_console_window(&mut self) -> &mut Self {
        use std::os::windows::process::CommandExt;
        if child_would_get_its_own_console() {
            self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
        } else {
            self
        }
    }
}

#[cfg(windows)]
impl HideConsoleWindow for tokio::process::Command {
    fn hide_console_window(&mut self) -> &mut Self {
        // tokio exposes `creation_flags` as an inherent method on Windows;
        // `CommandExt` is not implemented for this type.
        if child_would_get_its_own_console() {
            self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
        } else {
            self
        }
    }
}

#[cfg(not(windows))]
impl HideConsoleWindow for std::process::Command {
    fn hide_console_window(&mut self) -> &mut Self {
        self
    }
}

#[cfg(not(windows))]
impl HideConsoleWindow for tokio::process::Command {
    fn hide_console_window(&mut self) -> &mut Self {
        self
    }
}

/// Hands a script to a shell: the shell's own options, then the script.
///
/// On Windows, when the shell is cmd.exe, the script is passed as
/// `/S /C "<script>"` on the raw command line instead of as an argument.
/// `Command::arg` quotes an argument for the Microsoft C runtime, turning each
/// `"` into `\"`, and cmd does not undo that: `node "my server.js"` would reach
/// it as `node \"my server.js\"`. With `/S`, cmd strips the outer pair of
/// quotes and runs the rest exactly as written.
///
/// Implemented for both `std::process::Command` and `tokio::process::Command`.
pub(crate) trait ShellScript {
    /// `program` is the shell the command was created for, and `options` the
    /// arguments that come before the script, such as `-c` or `/C`.
    fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self;
}

/// For cmd.exe run with `/C` as its last option, the options to pass before
/// the script, and the raw text that carries the script.
///
/// `None` for any other shell, which takes the script as a normal argument.
/// Platform-independent so that it can be tested everywhere; only Windows
/// acts on it.
#[cfg(any(windows, test))]
fn cmd_raw_script<'a>(
    program: &str,
    options: &'a [String],
    script: &str,
) -> Option<(&'a [String], String)> {
    // Split by hand rather than with `Path`, which does not treat `\` as a
    // separator outside Windows and so could not be tested there.
    let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
    let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
    let is_cmd = stem.eq_ignore_ascii_case("cmd");
    let (flag, leading) = options.split_last()?;
    if !is_cmd || !flag.eq_ignore_ascii_case("/c") {
        return None;
    }
    // `/S` has to come before `/C`: everything after `/C` is the command.
    let strip = if leading.iter().any(|o| o.eq_ignore_ascii_case("/s")) {
        ""
    } else {
        "/S "
    };
    Some((leading, format!("{strip}{flag} \"{script}\"")))
}

#[cfg(windows)]
impl ShellScript for std::process::Command {
    fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
        use std::os::windows::process::CommandExt;
        match cmd_raw_script(program, options, script) {
            Some((leading, raw)) => self.args(leading).raw_arg(raw),
            None => self.args(options).arg(script),
        }
    }
}

#[cfg(windows)]
impl ShellScript for tokio::process::Command {
    fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
        // tokio exposes `raw_arg` as an inherent method on Windows.
        match cmd_raw_script(program, options, script) {
            Some((leading, raw)) => self.args(leading).raw_arg(raw),
            None => self.args(options).arg(script),
        }
    }
}

#[cfg(not(windows))]
impl ShellScript for std::process::Command {
    fn shell_script(&mut self, _program: &str, options: &[String], script: &str) -> &mut Self {
        self.args(options).arg(script)
    }
}

#[cfg(not(windows))]
impl ShellScript for tokio::process::Command {
    fn shell_script(&mut self, _program: &str, options: &[String], script: &str) -> &mut Self {
        self.args(options).arg(script)
    }
}

impl std::fmt::Display for Shell {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Shell::Sh => write!(f, "sh"),
            Shell::Bash => write!(f, "bash"),
            Shell::Zsh => write!(f, "zsh"),
            Shell::Fish => write!(f, "fish"),
            Shell::Cmd => write!(f, "cmd"),
            Shell::PowerShell => write!(f, "powershell"),
        }
    }
}

impl std::str::FromStr for Shell {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "sh" => Ok(Shell::Sh),
            "bash" => Ok(Shell::Bash),
            "zsh" => Ok(Shell::Zsh),
            "fish" => Ok(Shell::Fish),
            "cmd" => Ok(Shell::Cmd),
            "powershell" | "pwsh" => Ok(Shell::PowerShell),
            _ => Err(format!("unknown shell: {s}")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_shell_program() {
        assert_eq!(Shell::Sh.program(), "sh");
        assert_eq!(Shell::Bash.program(), "bash");
        assert_eq!(Shell::Zsh.program(), "zsh");
        assert_eq!(Shell::Fish.program(), "fish");
        assert_eq!(Shell::Cmd.program(), "cmd");
    }

    #[test]
    fn test_shell_exec_args() {
        assert_eq!(Shell::Sh.exec_args("echo hello"), vec!["-c", "echo hello"]);
        assert_eq!(
            Shell::Bash.exec_args("echo hello"),
            vec!["-c", "echo hello"]
        );
        assert_eq!(Shell::Cmd.exec_args("echo hello"), vec!["/C", "echo hello"]);
        assert_eq!(
            Shell::PowerShell.exec_args("echo hello"),
            vec!["-Command", "echo hello"]
        );
    }

    #[test]
    fn test_shell_from_str() {
        assert_eq!("sh".parse::<Shell>().unwrap(), Shell::Sh);
        assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
        assert_eq!("BASH".parse::<Shell>().unwrap(), Shell::Bash);
        assert_eq!("powershell".parse::<Shell>().unwrap(), Shell::PowerShell);
        assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::PowerShell);
        assert!("unknown".parse::<Shell>().is_err());
    }

    #[test]
    fn test_shell_display() {
        assert_eq!(Shell::Sh.to_string(), "sh");
        assert_eq!(Shell::Bash.to_string(), "bash");
        assert_eq!(Shell::Cmd.to_string(), "cmd");
    }

    #[test]
    fn test_default_shell() {
        // Default should be Sh (or Cmd on Windows)
        let default = Shell::default_for_platform();
        #[cfg(unix)]
        assert_eq!(default, Shell::Sh);
        #[cfg(windows)]
        assert_eq!(default, Shell::Cmd);
    }

    /// Checks that `hide_console_window` is available for both command types,
    /// chains inside a builder expression, and leaves spawning intact.
    ///
    /// This does not assert that no console window appears: the reliable
    /// oracles for that are version-dependent Windows behaviour, so the
    /// absence of a window is verified manually instead.
    #[test]
    fn test_hide_console_window() {
        let program = if cfg!(windows) { "cmd" } else { "echo" };
        let args: Vec<&str> = if cfg!(windows) {
            vec!["/C", "echo hi"]
        } else {
            vec!["hi"]
        };

        let output = std::process::Command::new(program)
            .args(&args)
            .hide_console_window()
            .output()
            .expect("spawning the child should succeed");
        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");

        // Building a tokio command needs no runtime, so this pins the second
        // impl without making the test async.
        let mut async_command = tokio::process::Command::new(program);
        async_command.args(&args).hide_console_window();
    }

    fn strings(words: &[&str]) -> Vec<String> {
        words.iter().map(|w| w.to_string()).collect()
    }

    #[test]
    fn test_cmd_raw_script_wraps_the_script_for_cmd() {
        let options = strings(&["/C"]);
        let (leading, raw) = cmd_raw_script("cmd", &options, r#"echo "a b""#).unwrap();
        assert!(leading.is_empty());
        assert_eq!(raw, r#"/S /C "echo "a b"""#);

        // Any spelling of cmd.exe, and any case of the flags.
        let options = strings(&["/d", "/s", "/c"]);
        let (leading, raw) =
            cmd_raw_script(r"C:\Windows\System32\CMD.EXE", &options, "echo hi").unwrap();
        assert_eq!(leading, &options[..2]);
        assert_eq!(raw, r#"/c "echo hi""#);
    }

    #[test]
    fn test_cmd_raw_script_leaves_other_shells_alone() {
        assert_eq!(cmd_raw_script("sh", &strings(&["-c"]), "echo hi"), None);
        assert_eq!(
            cmd_raw_script("pwsh", &strings(&["-Command"]), "echo hi"),
            None
        );
        // cmd without /C last is not running a script this way.
        assert_eq!(cmd_raw_script("cmd", &strings(&["/K"]), "echo hi"), None);
        assert_eq!(cmd_raw_script("cmd", &[], "echo hi"), None);
    }

    /// The case the raw command line exists for: quotes in the script reach
    /// cmd.exe as written, including a quoted program path with spaces.
    #[cfg(windows)]
    #[test]
    fn test_cmd_runs_a_script_with_quotes() {
        let dir = tempfile::tempdir().unwrap();
        let script_dir = dir.path().join("with space");
        std::fs::create_dir_all(&script_dir).unwrap();
        let script = script_dir.join("say.cmd");
        std::fs::write(&script, "@echo [%~1]\r\n").unwrap();

        let run = format!(r#""{}" "a b""#, script.display());
        let output = std::process::Command::new("cmd")
            .shell_script("cmd", &strings(&["/C"]), &run)
            .output()
            .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "[a b]");

        let output = Shell::Cmd.std_command(r#"echo "a b""#).output().unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), r#""a b""#);
    }
}