start-command 0.35.0

Gamification of coding, execute any command with ability to auto-report issues on GitHub
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
//! Shell command classification and argv rebuilding shared by isolation backends.

use std::process::{Command, Stdio};

use super::{is_debug, IsolationOptions};

const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];

/// Quoting dialect of the shell that will run a rebuilt command line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellQuotingStyle {
    /// `sh`, `bash`, `zsh`: a quote is escaped by closing, escaping and reopening.
    Posix,
    /// PowerShell: a quote inside a literal string is escaped by doubling it.
    PowerShell,
}

impl ShellQuotingStyle {
    /// The dialect of the host shell (`powershell.exe` on Windows, `sh` elsewhere).
    pub fn host() -> Self {
        if cfg!(windows) {
            ShellQuotingStyle::PowerShell
        } else {
            ShellQuotingStyle::Posix
        }
    }
}

/// True when every character is read literally by the target shell, so no quoting is needed.
///
/// The PowerShell set is narrower than the POSIX one: `,` builds an array, `@`
/// splats and `%` is an alias for ForEach-Object.
fn is_safe_arg(arg: &str, style: ShellQuotingStyle) -> bool {
    let extra = match style {
        ShellQuotingStyle::Posix => "_@%+=:,./^-",
        ShellQuotingStyle::PowerShell => "_=:./\\-",
    };
    !arg.is_empty()
        && arg
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || extra.contains(c))
}

/// Quote one argv element so the target shell parses it back as exactly that element (issue #164).
pub fn quote_shell_arg_with(arg: &str, style: ShellQuotingStyle) -> String {
    if is_safe_arg(arg, style) {
        return arg.to_string();
    }
    match style {
        ShellQuotingStyle::Posix => format!("'{}'", arg.replace('\'', "'\\''")),
        ShellQuotingStyle::PowerShell => format!("'{}'", arg.replace('\'', "''")),
    }
}

/// Quote one argv element for the host shell (issue #164).
pub fn quote_shell_arg(arg: &str) -> String {
    quote_shell_arg_with(arg, ShellQuotingStyle::host())
}

/// Rebuild a shell command line from the argv the user typed, for a given shell dialect.
///
/// A single element is a shell script the user quoted as a whole (`$ "ls | wc -l"`)
/// and is kept verbatim; multiple elements were split by the outer shell, so each
/// one is quoted to survive the inner shell unchanged.
pub fn build_command_string_with(argv: &[String], style: ShellQuotingStyle) -> String {
    match argv {
        [] => String::new(),
        [single] => single.clone(),
        _ => argv
            .iter()
            .map(|arg| quote_shell_arg_with(arg, style))
            .collect::<Vec<_>>()
            .join(" "),
    }
}

/// Rebuild a shell command line from the argv the user typed, for the host shell (issue #164).
pub fn build_command_string(argv: &[String]) -> String {
    build_command_string_with(argv, ShellQuotingStyle::host())
}

/// Split a command line into shell words, reversing `quote_shell_arg_with` for
/// the same dialect: a POSIX shell escapes with a backslash, PowerShell has no
/// backslash escape (it is a path separator) and writes a literal quote as a
/// doubled one.
///
/// Returns `None` when quoting is unbalanced.
pub fn split_shell_words_with(command: &str, style: ShellQuotingStyle) -> Option<Vec<String>> {
    let is_powershell = style == ShellQuotingStyle::PowerShell;
    let mut words: Vec<String> = Vec::new();
    let mut current = String::new();
    let mut started = false;
    let mut quote: Option<char> = None;
    let mut chars = command.chars().peekable();

    while let Some(c) = chars.next() {
        if quote.is_none() && c.is_whitespace() {
            if started {
                words.push(std::mem::take(&mut current));
                started = false;
            }
            continue;
        }
        started = true;
        if !is_powershell && c == '\\' && quote != Some('\'') {
            if quote == Some('"') && chars.peek().is_some_and(|next| !"$`\"\\\n".contains(*next)) {
                current.push(c);
                continue;
            }
            let escaped = chars.next()?;
            current.push(escaped);
            continue;
        }
        if quote.is_none() && (c == '\'' || c == '"') {
            quote = Some(c);
            continue;
        }
        if Some(c) == quote {
            if is_powershell && chars.peek() == Some(&c) {
                chars.next();
                current.push(c);
                continue;
            }
            quote = None;
            continue;
        }
        current.push(c);
    }

    if quote.is_some() {
        return None;
    }
    if started {
        words.push(current);
    }
    Some(words)
}

/// Split a command line into shell words for the host shell dialect.
pub fn split_shell_words(command: &str) -> Option<Vec<String>> {
    split_shell_words_with(command, ShellQuotingStyle::host())
}

/// Split a command into words, falling back to whitespace splitting when quoting is unbalanced.
pub fn to_shell_words(command: &str) -> Vec<String> {
    split_shell_words(command).unwrap_or_else(|| {
        command
            .split_whitespace()
            .map(|part| part.to_string())
            .collect()
    })
}

fn basename(part: &str) -> &str {
    part.rsplit('/').next().unwrap_or(part)
}

fn is_shell_command(parts: &[String]) -> bool {
    parts
        .first()
        .is_some_and(|first| SHELL_NAMES.contains(&basename(first)))
}

/// Literal argv are safe only if no outer shell evaluation is required (#202).
/// Quoted operators inside the script are literals; expansions in double quotes
/// and operators outside quotes must be evaluated by the outer command shell.
fn simple_shell_words(command: &str) -> Option<Vec<String>> {
    let mut quote = None;
    let mut chars = command.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' && quote != Some('\'') {
            if chars.peek() == Some(&'\n') {
                return None;
            }
            if quote != Some('"') || chars.peek().is_some_and(|next| "$`\"\\\n".contains(*next)) {
                chars.next();
            }
            continue;
        }
        if quote == Some('\'') {
            if c == '\'' {
                quote = None;
            }
            continue;
        }
        if c == '$' || c == '`' {
            return None;
        }
        if quote == Some('"') {
            if c == '"' {
                quote = None;
            }
            continue;
        }
        if ";&|<>(){}\n\r*?[]~#".contains(c) || (c.is_whitespace() && !" \t".contains(c)) {
            return None;
        }
        if c == '\'' || c == '"' {
            quote = Some(c);
        }
    }
    split_shell_words_with(command, ShellQuotingStyle::Posix)
}

/// True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84).
pub fn is_interactive_shell_command(command: &str) -> bool {
    let Some(parts) = simple_shell_words(command) else {
        return false;
    };
    is_shell_command(&parts) && !parts.iter().any(|part| part == "-c")
}

/// True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91).
pub fn is_shell_invocation_with_args(command: &str) -> bool {
    let Some(parts) = simple_shell_words(command) else {
        return false;
    };
    let Some(index) = parts.iter().position(|part| part == "-c") else {
        return false;
    };
    is_shell_command(&parts)
        && index > 0
        && index + 1 < parts.len()
        && parts[1..index].iter().all(|part| {
            let flag = part.strip_prefix("--").or_else(|| part.strip_prefix('-'));
            flag.is_some_and(|flag| {
                !flag.is_empty() && flag.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
            })
        })
}

/// Build Docker argv without folding command operators or positional arguments.
pub(crate) fn docker_shell_args(command: &str, shell: &str, interactive: bool) -> Vec<String> {
    if is_interactive_shell_command(command) {
        let mut args = to_shell_words(command);
        if interactive
            && ["bash", "zsh"].contains(&basename(&args[0]))
            && !args.iter().any(|arg| arg == "-i")
        {
            args.insert(1, "-i".to_string());
        }
        return args;
    }
    if is_shell_invocation_with_args(command) {
        return build_shell_with_args_cmd_args(command);
    }
    let mut args = vec![shell.to_string()];
    if ["bash", "zsh"].contains(&basename(shell)) {
        args.push("-i".to_string());
    }
    args.extend(["-c".to_string(), command.to_string()]);
    args
}

/// Preserve the script word and all positional argv separately (#202).
pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
    simple_shell_words(command).unwrap_or_else(|| to_shell_words(command))
}

/// Quote an argument for display only, keeping the user-facing double-quoted form (issue #91).
fn quote_for_display(arg: &str) -> String {
    // Display is cosmetic, so it always uses the POSIX set: the rendered command
    // must look the same on every platform.
    if is_safe_arg(arg, ShellQuotingStyle::Posix) {
        arg.to_string()
    } else if arg.contains('"') {
        format!("'{}'", arg)
    } else {
        format!("\"{}\"", arg)
    }
}

/// Build a display string that shows the argument boundaries the user typed (issues #91, #164).
///
/// A command the user quoted as one shell script is shown verbatim, because its
/// metacharacters are meant for the shell, not for display.
pub fn build_display_command(command: &str) -> String {
    if is_shell_invocation_with_args(command) {
        return build_shell_with_args_cmd_args(command)
            .iter()
            .map(|arg| quote_for_display(arg))
            .collect::<Vec<_>>()
            .join(" ");
    }
    match split_shell_words(command) {
        Some(words) if build_command_string(&words) == command => words
            .iter()
            .map(|arg| quote_for_display(arg))
            .collect::<Vec<_>>()
            .join(" "),
        _ => command.to_string(),
    }
}

/// First word of a command line, used for failure reports and log headers.
pub fn command_name(command: &str) -> String {
    to_shell_words(command)
        .into_iter()
        .next()
        .unwrap_or_default()
}

/// Detect the best available shell in an isolation environment (docker/ssh)
/// Tries shells in order: bash, zsh, sh
/// Returns the shell path to use
pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
    let shell_preference = &options.shell;

    // If a specific shell is requested (not auto), use it directly
    if !shell_preference.is_empty() && shell_preference != "auto" {
        if is_debug() {
            eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
        }
        return shell_preference.clone();
    }

    // In auto mode, try shells in order of preference
    let shells_to_try = ["bash", "zsh", "sh"];

    if environment == "docker" {
        let image = match &options.image {
            Some(i) => i.clone(),
            None => return "sh".to_string(),
        };

        for shell in &shells_to_try {
            let result = Command::new("docker")
                .args([
                    "run",
                    "--rm",
                    &image,
                    "sh",
                    "-c",
                    &format!("command -v {}", shell),
                ])
                .stdout(Stdio::piped())
                .stderr(Stdio::null())
                .output();

            if let Ok(output) = result {
                if output.status.success() {
                    let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
                    if !detected.is_empty() {
                        if is_debug() {
                            eprintln!(
                                "[DEBUG] Detected shell in docker image {}: {}",
                                image, detected
                            );
                        }
                        return detected;
                    }
                }
            }
        }

        if is_debug() {
            eprintln!(
                "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
                image
            );
        }
        return "sh".to_string();
    }

    if environment == "ssh" {
        let endpoint = match &options.endpoint {
            Some(e) => e.clone(),
            None => return "sh".to_string(),
        };

        // Run a single SSH command to check for available shells in order
        let check_cmd: Vec<String> = shells_to_try
            .iter()
            .map(|s| format!("command -v {}", s))
            .collect();
        let check_cmd_str = check_cmd.join(" || ");

        let result = Command::new("ssh")
            .args([&endpoint, &check_cmd_str])
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .output();

        if let Ok(output) = result {
            if output.status.success() {
                let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
                if !detected.is_empty() {
                    if is_debug() {
                        eprintln!(
                            "[DEBUG] Detected shell on SSH host {}: {}",
                            endpoint, detected
                        );
                    }
                    return detected;
                }
            }
        }

        if is_debug() {
            eprintln!(
                "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
                endpoint
            );
        }
        return "sh".to_string();
    }

    "sh".to_string()
}