mise 2026.9.2

Dev tools, env vars, and tasks in one CLI
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
use std::path::{Path, PathBuf};

mod config;
mod devcontainer;
mod git_pre_commit;
mod github_action;
mod install_script;
mod task_docs;
mod task_stubs;
mod tool_stub;

/// Generate files for various tools/services
#[derive(Debug, usage_rs::Args)]
#[usage(visible_alias = "gen", alias = "g")]
pub(crate) struct Generate {
    #[usage(subcommand)]
    command: Commands,
}

#[derive(Debug, usage_rs::Subcommands)]
enum Commands {
    /// Deprecated. Use `mise generate install-script` instead
    // Renamed because `bootstrap` read as a form of `mise bootstrap` (machine setup), which this
    // command has nothing to do with.
    #[usage(hide)]
    Bootstrap(install_script::InstallScript),
    Config(config::Config),
    Devcontainer(devcontainer::Devcontainer),
    GitPreCommit(git_pre_commit::GitPreCommit),
    GithubAction(github_action::GithubAction),
    InstallScript(install_script::InstallScript),
    TaskDocs(task_docs::TaskDocs),
    TaskStubs(task_stubs::TaskStubs),
    ToolStub(tool_stub::ToolStub),
}

impl Commands {
    pub(crate) async fn run(self) -> eyre::Result<()> {
        match self {
            Self::Bootstrap(cmd) => {
                deprecated_at!(
                    "2026.9.0",
                    "2027.9.0",
                    "cli.generate.bootstrap",
                    "`mise generate bootstrap` is deprecated. Use `mise generate install-script` instead."
                );
                cmd.run().await
            }
            Self::Config(cmd) => cmd.run().await,
            Self::Devcontainer(cmd) => cmd.run().await,
            Self::GitPreCommit(cmd) => cmd.run().await,
            Self::GithubAction(cmd) => cmd.run().await,
            Self::InstallScript(cmd) => cmd.run().await,
            Self::TaskDocs(cmd) => cmd.run().await,
            Self::TaskStubs(cmd) => cmd.run().await,
            Self::ToolStub(cmd) => cmd.run().await,
        }
    }
}

impl Generate {
    pub(crate) async fn run(self) -> eyre::Result<()> {
        self.command.run().await
    }
}

/// Where the Windows launcher for a generated stub goes, or `None` when the stub does not want one.
///
/// Stubs are shebang scripts, which Windows will not execute, so anything generated to be run as a
/// command needs a `.cmd` beside it. Skipped when the stub's own name already ends in an executable
/// extension, so `mytool.cmd` does not grow a `mytool.cmd.cmd`.
pub(super) fn windows_launcher_path(stub: &Path) -> Option<PathBuf> {
    windows_launcher_path_with_ext(stub, "cmd")
}

/// Where the native Windows launcher for a generated stub goes.
///
/// The `.exe` form of [`windows_launcher_path`], for `mise generate task-stubs
/// --windows-launcher=exe`. Same name, same guard: the two forms answer to the same bare name
/// from a shell and only one of them may exist at a time.
pub(super) fn windows_exe_launcher_path(stub: &Path) -> Option<PathBuf> {
    windows_launcher_path_with_ext(stub, "exe")
}

fn windows_launcher_path_with_ext(stub: &Path, launcher_ext: &str) -> Option<PathBuf> {
    let name = stub.file_name()?.to_str()?;
    let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
    if matches!(ext.as_deref(), Some("cmd" | "bat" | "exe")) {
        return None;
    }
    Some(stub.with_file_name(format!("{name}.{launcher_ext}")))
}

/// Marks a `.cmd` as generated, so regeneration can tell its own launcher from a hand-written one.
///
/// The stub itself carries `# generated by mise task-stubs` for the same reason; a launcher needs
/// its own because its body varies per task and so cannot be recognised by comparison.
pub(super) const WINDOWS_LAUNCHER_MARKER: &str = "rem generated by mise";

/// The body of a Windows launcher that runs `command` with the caller's arguments.
///
/// `%*` alone does not carry them. cmd.exe parses the whole command line before a batch file runs,
/// so calling one from PowerShell — which passes the argument to `cmd /c` unquoted, because it has
/// no reason to know the target is a batch file — loses `& ^ | " < >` and expands `%VAR%` before
/// `%*` ever expands. Measured against the same arguments through `mise run`: 9 of 17 shapes
/// arrived different. A native `.exe` is handed argv directly and has none of this, which is why
/// `--windows-launcher=exe` exists and why the shim default is `exe`.
///
/// What cmd destroys on the way in it also preserves: the original text is still in its
/// `CMDCMDLINE` pseudo-variable. So the launcher copies that into a real variable — `!CMDCMDLINE!`
/// with delayed expansion, because `%CMDCMDLINE%` is substituted before special characters are
/// parsed and truncates at the first `&` — and hands it to mise, which re-splits it with the rules
/// a native program's runtime uses. It goes through the environment rather than back onto the
/// command line so cmd never gets a second chance to parse it.
///
/// The guard decides whether cmd was spawned *for* this launcher. When it was not — an interactive
/// prompt, a `call` from another batch file — the arguments were already split by the shell exactly
/// as they would have been for a native program, so `%*` is what mise gets and the script must not
/// `exit`, which would close the caller's shell. When it was, `exit` (rather than `exit /b`) also
/// stops cmd running whatever it queued from the same line: given `hello.cmd c&d`, cmd intends to
/// run `d` afterwards, and letting it would report that failure as the task's exit code.
///
/// One case stays ambiguous, and cannot be resolved: `cmd /c ""<launcher>" a & b"` is what a shell
/// produces for the single argument `a & b`, and also what someone writes by hand to run the
/// launcher and then `b`. It is read as the former.
pub(super) fn windows_launcher_body(command: &str) -> String {
    let raw = crate::env::LAUNCHER_RAW_CMDLINE_ENV;
    let path = crate::env::LAUNCHER_PATH_ENV;
    let sentinel = crate::env::LAUNCHER_ARGS_SENTINEL;
    [
        "@echo off",
        WINDOWS_LAUNCHER_MARKER,
        // `%~f0` is captured before delayed expansion is on, so a `!` in the path survives.
        "setlocal DisableDelayedExpansion",
        &format!("set \"{path}=%~f0\""),
        "setlocal EnableDelayedExpansion",
        &format!("set \"{raw}=!CMDCMDLINE!\""),
        &format!("if \"!{raw}!\"==\"!{raw}:%{path}%=!\" goto mise_launcher_fallback"),
        &format!("{command} {sentinel} %*"),
        "exit !ERRORLEVEL!",
        ":mise_launcher_fallback",
        // Cleared, or a value inherited from an outer launcher would be recovered as this one's.
        &format!("set \"{raw}=\""),
        &format!("set \"{path}=\""),
        &format!("{command} {sentinel} %*"),
    ]
    .join("\r\n")
        + "\r\n"
}

/// Index of the line carrying `command` in the body [`windows_launcher_body`] builds.
const WINDOWS_LAUNCHER_COMMAND_LINE: usize = 7;

/// Recognise a `.cmd` this crate generated, so regeneration can replace or remove its own launcher
/// without touching one the user wrote.
///
/// Ownership rests on [`WINDOWS_LAUNCHER_MARKER`]. The body is never compared against one fixed
/// string: it embeds the command, so it changes with `--mise-bin`, with the task name, and with the
/// stub path — a launcher written by an earlier run with different arguments is still ours, and
/// comparison against a single expected body would call it a stranger and leave it behind. Instead
/// the command is read back out of the file and the body rebuilt around it, which keeps the marker
/// as the thing that says "mise wrote this" while still refusing a file with anything appended, as
/// the old three-line check did by counting lines.
///
/// The consequence is that **every past spelling of the body has to stay recognised here**. These
/// files are committed, so a project generated by an older mise carries an older body, and the one
/// mise can no longer recognise is exactly the one it needs to replace. There is one so far: the
/// three-line form from before launchers recovered their arguments.
pub(super) fn is_generated_launcher(contents: &str) -> bool {
    let lines: Vec<&str> = contents.lines().collect();
    if lines.first() != Some(&"@echo off") || lines.get(1) != Some(&WINDOWS_LAUNCHER_MARKER) {
        return false;
    }
    // The spelling before launchers recovered their arguments: three lines, the last running the
    // command with `%*`. Still ours. These files are committed, so a project generated by an older
    // mise would otherwise fail regeneration with "is not a generated launcher" — and the launcher
    // it could no longer recognise is exactly the one that needs replacing.
    if lines.len() == 3 {
        return lines[2].ends_with(" %*");
    }
    // The current spelling, rebuilt from the command it carries and compared line for line. That
    // keeps the marker as the thing that says "mise wrote this" while still refusing a file with
    // anything appended to it, which comparison by shape alone could not.
    let suffix = format!(" {} %*", crate::env::LAUNCHER_ARGS_SENTINEL);
    let Some(command) = lines
        .get(WINDOWS_LAUNCHER_COMMAND_LINE)
        .copied()
        .and_then(|line| line.strip_suffix(&suffix))
    else {
        return false;
    };
    windows_launcher_body(command).lines().eq(lines)
}

/// Quote one word of a generated `.cmd` so cmd.exe passes it through unchanged.
///
/// Quoting alone is not enough. Measured on Windows against a real `cmd.exe`:
///
/// - unquoted, a path containing `&` runs the text before it as a command
///   (`'...\cmdtest\a' is not recognized`); quoting fixes it, because inside `"..."` cmd stops
///   treating `& | < > ^ ( )` as syntax
/// - `%NAME%` is expanded *inside* quotes too, so a literal `%` has to be written `%%` — the
///   batch-file spelling. `"C:\%FOO%\mise.exe"` came out as `C:\INJECTED\mise.exe`
///
/// Quoting unconditionally rather than only when it is needed: a quoted bare name still resolves
/// through `PATH` (verified), so there is no case where the quotes cost anything, and one rule is
/// easier to keep correct than a predicate over cmd's metacharacter set.
pub(super) fn cmd_quote(s: &str) -> String {
    format!("\"{}\"", s.replace('%', "%%"))
}

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

    #[test]
    fn launcher_sits_beside_the_stub() {
        assert_eq!(
            windows_launcher_path(Path::new("bin/hello")),
            Some(PathBuf::from("bin/hello.cmd"))
        );
        // a dot that is not an executable extension is just part of the name
        assert_eq!(
            windows_launcher_path(Path::new("my.tool")),
            Some(PathBuf::from("my.tool.cmd"))
        );
    }

    #[test]
    fn a_name_that_is_already_executable_gets_none() {
        for name in ["mytool.cmd", "mytool.BAT", "mytool.exe"] {
            assert_eq!(windows_launcher_path(Path::new(name)), None, "{name}");
            // The same guard, or the `.exe` form would write `mytool.exe.exe` -- and, worse,
            // would happily overwrite `mytool.exe` itself for a stub named that.
            assert_eq!(windows_exe_launcher_path(Path::new(name)), None, "{name}");
        }
    }

    #[test]
    fn the_two_launcher_forms_share_a_name() {
        // They are alternatives for one stub, and cleanup relies on deriving one path from the
        // other, so they have to differ in nothing but the extension.
        let stub = Path::new("bin/hello");
        assert_eq!(
            windows_launcher_path(stub),
            Some(PathBuf::from("bin/hello.cmd"))
        );
        assert_eq!(
            windows_exe_launcher_path(stub),
            Some(PathBuf::from("bin/hello.exe"))
        );
    }

    #[test]
    fn body_forwards_arguments() {
        let body = windows_launcher_body("mise run hello");
        let lines: Vec<&str> = body.lines().collect();
        assert_eq!(lines[0], "@echo off");
        assert_eq!(lines[1], WINDOWS_LAUNCHER_MARKER);
        // Both branches run the command; `%*` is what cmd delivered and the sentinel is where the
        // recovered arguments replace it.
        let expected = format!("mise run hello {} %*", crate::env::LAUNCHER_ARGS_SENTINEL);
        assert_eq!(lines[WINDOWS_LAUNCHER_COMMAND_LINE], expected);
        assert_eq!(lines[lines.len() - 1], expected);
        // CRLF, because a batch file with bare LF has been reported to misbehave under some cmd
        // builds and every other line mise writes here already uses it.
        assert!(body.ends_with("\r\n"), "{body:?}");
        assert!(!body.contains("\n\n"), "{body:?}");
    }

    #[test]
    fn the_shim_body_carries_the_same_recovery() {
        // `shims::windows_file_shim_body` writes the same protocol for a "file"-mode shim, with a
        // recursion guard of its own in front. The two are separate because that guard has to run
        // first and because `is_generated_launcher` pins this body's line layout -- but a fix
        // applied to one and not the other would be silent, so compare the part they share.
        fn recovery(body: &str, label: &str, command: &str) -> Vec<String> {
            body.lines()
                .skip_while(|l| !l.contains("CMDCMDLINE"))
                .map(|l| l.replace(label, "LABEL").replace(command, "COMMAND"))
                .collect()
        }
        assert_eq!(
            recovery(
                &crate::shims::windows_file_shim_body("hello"),
                "mise_shim_fallback",
                "mise x -- hello",
            ),
            recovery(
                &windows_launcher_body("mise run hello"),
                "mise_launcher_fallback",
                "mise run hello",
            ),
        );
    }

    #[test]
    fn the_command_line_index_matches_the_body() {
        // `is_generated_launcher` reads the command back out of the file by this index, so a line
        // added to the body above without moving it would silently stop recognising our own
        // launchers -- and regeneration would then refuse to replace them.
        let body = windows_launcher_body("SOME-UNIQUE-COMMAND");
        let lines: Vec<&str> = body.lines().collect();
        assert!(
            lines[WINDOWS_LAUNCHER_COMMAND_LINE].starts_with("SOME-UNIQUE-COMMAND "),
            "{:?}",
            lines[WINDOWS_LAUNCHER_COMMAND_LINE]
        );
    }

    #[test]
    fn the_guard_and_the_exit_are_present() {
        // The two properties the body exists for, pinned by name: without the guard an
        // interactive `cmd` session would be closed by the `exit`, and without the `exit` cmd
        // would run whatever it queued after an unquoted `&` and report that as the exit code.
        let body = windows_launcher_body("mise run hello");
        assert!(body.contains("goto mise_launcher_fallback"), "{body:?}");
        assert!(body.contains(":mise_launcher_fallback"), "{body:?}");
        assert!(body.contains("\r\nexit !ERRORLEVEL!\r\n"), "{body:?}");
        // Delayed expansion, not `%CMDCMDLINE%`: the percent form is substituted before special
        // characters are parsed and truncates the line at the first `&`.
        assert!(body.contains("!CMDCMDLINE!"), "{body:?}");
        assert!(!body.contains("%CMDCMDLINE%"), "{body:?}");
    }

    #[test]
    fn every_body_carries_the_ownership_marker() {
        // Regeneration refuses to replace a `.cmd` it cannot recognise, so the marker has to be
        // present in whatever the generators produce, not only in the one example above.
        for command in ["mise run hello", r#"mise tool-stub "%~dpn0""#] {
            let body = windows_launcher_body(command);
            assert!(
                body.lines().nth(1) == Some(WINDOWS_LAUNCHER_MARKER),
                "{body:?}"
            );
        }
    }

    #[test]
    fn a_launcher_is_recognised_whatever_command_it_carries() {
        // The point of recognising by marker rather than by comparison: these bodies differ from
        // each other and from whatever the current run would produce -- a changed `--mise-bin`, a
        // renamed task, an older mise -- and every one of them is still ours to replace or remove.
        for command in [
            "mise run hello",
            r#""C:\Program Files\mise.exe" run hello"#,
            r#"mise tool-stub "%~dpn0""#,
            "some-other-binary run build",
        ] {
            assert!(
                is_generated_launcher(&windows_launcher_body(command)),
                "{command}"
            );
        }
    }

    #[test]
    fn a_launcher_from_before_argument_recovery_is_still_ours() {
        // The regression that matters most here. Launchers are committed, so every project
        // generated by an older mise has the three-line spelling checked in. Failing to recognise
        // it would make `mise generate task-stubs` bail with "is not a generated launcher" on the
        // very files it needs to replace.
        for command in [
            "mise run hello",
            r#""mise" run "build:all""#,
            r#"mise tool-stub "%~dpn0""#,
        ] {
            let old = format!("@echo off\r\n{WINDOWS_LAUNCHER_MARKER}\r\n{command} %*\r\n");
            assert!(is_generated_launcher(&old), "{old:?}");
        }
    }

    #[test]
    fn a_launcher_with_a_line_added_to_it_is_not_ours() {
        // The current body has many lines, so "no trailing content" can no longer be "exactly
        // three lines". It is enforced by rebuilding instead -- this is that check's control.
        let body = windows_launcher_body("mise run hello");
        assert!(is_generated_launcher(&body));
        assert!(!is_generated_launcher(&format!("{body}echo done\r\n")));
        // A line changed in the middle is not ours either: the file no longer does what mise
        // would write, and quietly overwriting someone's edit is the failure to avoid.
        let tampered = body.replace("exit !ERRORLEVEL!", "exit /b !ERRORLEVEL!");
        assert_ne!(tampered, body);
        assert!(!is_generated_launcher(&tampered), "{tampered:?}");
    }

    #[test]
    fn a_launcher_survives_line_ending_normalisation() {
        // These files are committed, so git may hand them back with LF. Recognition is by line,
        // never by the raw bytes, or a checkout with `core.autocrlf=input` would look foreign.
        let body = windows_launcher_body("mise run hello");
        assert!(is_generated_launcher(&body.replace("\r\n", "\n")));
    }

    #[test]
    fn a_launcher_without_the_marker_is_not_ours() {
        // The controls. A false positive here deletes or overwrites a file mise did not write, so
        // the shape alone must not be enough -- the first case is exactly what someone would write
        // by hand for the same purpose.
        for contents in [
            "@echo off\r\nmise run hello %*\r\n",
            "@echo off\r\nrem hand written\r\nmise run hello %*\r\n",
            "rem generated by mise\r\nmise run hello %*\r\n",
            // Trailing content: a launcher plus something the user appended is not ours to delete.
            "@echo off\r\nrem generated by mise\r\nmise run hello %*\r\necho done\r\n",
            // Not a launcher at all.
            "@echo off\r\nrem generated by mise\r\nmise run hello\r\n",
            "",
        ] {
            assert!(!is_generated_launcher(contents), "{contents:?}");
        }
    }

    #[test]
    fn quoting_survives_cmd_metacharacters() {
        assert_eq!(cmd_quote("mise"), "\"mise\"");
        assert_eq!(
            cmd_quote(r"C:\Program Files\mise.exe"),
            "\"C:\\Program Files\\mise.exe\""
        );
        // `&` would otherwise end the command; the quotes are what contain it
        assert_eq!(cmd_quote(r"C:\a&b\mise.exe"), "\"C:\\a&b\\mise.exe\"");
        // `%` is expanded even inside quotes, so it has to be doubled
        assert_eq!(cmd_quote(r"C:\p%c\mise.exe"), "\"C:\\p%%c\\mise.exe\"");
        assert_eq!(cmd_quote("%PATH%"), "\"%%PATH%%\"");
    }
}