pitchfork-cli 2.32.1

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
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
//! 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.
///
/// When the shell is PowerShell, a script that starts with a quoted string is
/// run with the call operator, as `& <script>`. See `powershell_script`.
///
/// 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;
}

/// The script as PowerShell should be given it.
///
/// PowerShell reads a statement that starts with a quoted string as an
/// expression rather than a command, so `"C:\Program Files\app\app.exe" --port 80`
/// fails to parse ("Unexpected token"), and a quoted path on its own just
/// prints itself. A quoted program path has to be run with the call operator,
/// `& "C:\Program Files\app\app.exe" --port 80`, which is what this adds when
/// the shell is `powershell` or `pwsh` with `-Command` (or `-c`) last.
///
/// The script is returned unchanged for any other shell, and for a script that
/// does not start with a quote.
pub(crate) fn powershell_script<'a>(
    program: &str,
    options: &[String],
    script: &'a str,
) -> std::borrow::Cow<'a, str> {
    let stem = program_stem(program);
    let is_powershell =
        stem.eq_ignore_ascii_case("powershell") || stem.eq_ignore_ascii_case("pwsh");
    let runs_command = options.last().is_some_and(|flag| {
        flag.eq_ignore_ascii_case("-command") || flag.eq_ignore_ascii_case("-c")
    });
    if is_powershell && runs_command && script.trim_start().starts_with(['"', '\'']) {
        format!("& {script}").into()
    } else {
        script.into()
    }
}

/// The file name of `program` without its extension: `cmd` for
/// `C:\Windows\System32\cmd.exe`.
///
/// Split by hand rather than with `Path`, which does not treat `\` as a
/// separator outside Windows and so could not be tested there.
fn program_stem(program: &str) -> &str {
    let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
    name.rsplit_once('.').map_or(name, |(stem, _)| stem)
}

/// 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)> {
    let is_cmd = program_stem(program).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(&*powershell_script(program, options, 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(&*powershell_script(program, options, 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(&*powershell_script(program, options, 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(&*powershell_script(program, options, 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""#);
    }

    #[test]
    fn test_powershell_script_calls_a_quoted_program() {
        let command = strings(&["-Command"]);
        assert_eq!(
            powershell_script("powershell", &command, r#""C:\a b\app.exe" --port 80"#),
            r#"& "C:\a b\app.exe" --port 80"#
        );
        assert_eq!(
            powershell_script(
                r"C:\Program Files\PowerShell\7\pwsh.exe",
                &strings(&["-NoProfile", "-c"]),
                "'app' x"
            ),
            "& 'app' x"
        );
        // Not quoted, not PowerShell, or not run as a command: unchanged.
        assert_eq!(
            powershell_script("pwsh", &command, "app --port 80"),
            "app --port 80"
        );
        assert_eq!(powershell_script("pwsh", &command, "& 'app'"), "& 'app'");
        assert_eq!(
            powershell_script("pwsh", &command, r#"echo "a b""#),
            r#"echo "a b""#
        );
        assert_eq!(
            powershell_script("cmd", &strings(&["/C"]), r#""app""#),
            r#""app""#
        );
        assert_eq!(powershell_script("sh", &strings(&["-c"]), "'app'"), "'app'");
        assert_eq!(
            powershell_script("pwsh", &strings(&["-File"]), "'app'"),
            "'app'"
        );
    }

    /// PowerShell runs a script that starts with a quoted program path,
    /// which it would otherwise fail to parse.
    #[cfg(windows)]
    #[test]
    fn test_powershell_runs_a_script_starting_with_a_quoted_path() {
        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();

        for run in [
            format!(r#""{}" "a b""#, script.display()),
            format!("'{}' 'a b'", script.display()),
        ] {
            let output = Shell::PowerShell.std_command(&run).output().unwrap();
            assert!(output.status.success(), "{run}: {output:?}");
            assert_eq!(
                String::from_utf8_lossy(&output.stdout).trim(),
                "[a b]",
                "{run}"
            );
        }
    }
}