diff --git a/docs/dev-tools/tool-stubs.md b/docs/dev-tools/tool-stubs.md index a12072163fc..f15e7104622 100644 --- a/docs/dev-tools/tool-stubs.md +++ b/docs/dev-tools/tool-stubs.md @@ -308,6 +308,28 @@ chmod +x ./bin/my-tool ./bin/my-tool --version ``` +#### On Windows + +Windows cannot execute a shebang script, so `mise generate tool-stub` writes a `.cmd` launcher +beside the stub. Run the stub by name and Windows picks it up through `PATHEXT`: + +```powershell +.\bin\my-tool.cmd --version +``` + +The launcher is generated whenever the stub could run on Windows — either it lists a +`[platforms.windows-*]` entry, or it names no platforms at all. A stub that ships only for, say, +Linux and macOS does not get one, and neither does a stub whose own name already ends in `.cmd`, +`.bat` or `.exe`. It is written on every platform, not just Windows, so a stub generated on Linux +and committed to a repository still works for someone who clones it on Windows. + +If a stub later stops shipping for Windows, regenerating it removes the launcher, so it cannot keep +running against platforms the stub no longer declares. Only a launcher mise generated is removed — +one you wrote yourself is left alone. + +The extension-less stub is kept as well: Git Bash and Cygwin run it through the shebang, the same +way [shims](/dev-tools/shims) place both an extension-less script and a native launcher on Windows. + ### Via mise Command Execute using the [`mise tool-stub`](/cli/tool-stub) command—useful for testing if something isn't working right: diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md index 4e6dc0e3097..d304c5fce72 100644 --- a/docs/tips-and-tricks.md +++ b/docs/tips-and-tricks.md @@ -76,10 +76,13 @@ downloads and runs the pinned mise binary for the project. If contributors work on Windows, add `--windows`. Windows cannot execute a shebang script, so `mise generate bootstrap --write ./bin/mise --windows` writes `bin/mise.cmd` alongside it and they -run `.\bin\mise.cmd`. It is generated on every platform, so a `bin/` committed from Linux or macOS -still works for someone who clones the repository on Windows. The launcher downloads the standalone -`mise.exe` for the release and checks it against a checksum embedded when the script was generated, -so it needs nothing beyond what Windows already ships. +run `.\bin\mise.cmd`. The launcher downloads the standalone `mise.exe` for the release and checks it +against a checksum embedded when the script was generated, so it needs nothing beyond what Windows +already ships. + +Task stubs get a `.cmd` launcher beside each stub for the same reason, so the Windows form of the +example above is `.\bin\test.cmd`. Both halves are generated on every platform, so a `bin/` +committed from Linux or macOS still works for someone who clones the repository on Windows. ## Machine bootstrapping diff --git a/e2e-win/task_stubs.Tests.ps1 b/e2e-win/task_stubs.Tests.ps1 new file mode 100644 index 00000000000..f52830f7816 --- /dev/null +++ b/e2e-win/task_stubs.Tests.ps1 @@ -0,0 +1,76 @@ +Describe 'task-stubs' { + BeforeAll { + $originalPath = Get-Location + # The workflow sets this for the whole job and Pester runs every suite in one process, so + # put back what was there rather than dropping it. + $originalTrustedConfigPaths = [Environment]::GetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', 'Process') + Set-Location TestDrive: + $env:MISE_TRUSTED_CONFIG_PATHS = $TestDrive + + @' +[tasks.hello] +run = "echo hi-from-task" + +[tasks."build:all"] +run = "echo hi-from-nested" +'@ | Out-File -FilePath 'mise.toml' -Encoding utf8NoBOM + + mise generate task-stubs | Out-Null + } + + AfterAll { + Set-Location $originalPath + if ($null -eq $originalTrustedConfigPaths) { + Remove-Item -Path Env:\MISE_TRUSTED_CONFIG_PATHS -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', $originalTrustedConfigPaths, 'Process') + } + } + + It 'writes a .cmd launcher beside each stub' { + # Windows will not execute the `#!/bin/sh` stub, so without this there is nothing to run. + Test-Path 'bin\hello' | Should -BeTrue + Test-Path 'bin\hello.cmd' | Should -BeTrue + (Get-Content 'bin\hello.cmd' -Raw) | Should -BeLike '*"mise" run "hello" %`**' + # Ownership marker: what regeneration and cleanup use to tell our file from the user's. + (Get-Content 'bin\hello.cmd' -Raw) | Should -BeLike '*rem generated by mise*' + } + + It 'refuses to overwrite a hand-written launcher' { + New-Item -ItemType Directory -Force 'owned-bin' | Out-Null + "@echo off`r`necho mine`r`n" | Out-File -FilePath 'owned-bin\hello.cmd' -Encoding utf8NoBOM -NoNewline + mise generate task-stubs --dir owned-bin 2>&1 | Out-String | + Should -BeLike '*is not a generated launcher*' + (Get-Content 'owned-bin\hello.cmd' -Raw) | Should -BeLike '*echo mine*' + # Validation runs before anything is written, so the stub is not there either. + Test-Path 'owned-bin\hello' | Should -BeFalse + } + + It 'runs the task through the launcher' { + # The point of the change: not that the file exists, but that it works. + & '.\bin\hello.cmd' | Select-Object -Last 1 | Should -Be 'hi-from-task' + } + + It 'covers nested tasks too' { + Test-Path 'bin\build\all.cmd' | Should -BeTrue + & '.\bin\build\all.cmd' | Select-Object -Last 1 | Should -Be 'hi-from-nested' + } + + It 'forwards arguments' { + @' +[tasks.echoer] +run = "echo got" +'@ | Out-File -FilePath 'mise.toml' -Encoding utf8NoBOM -Append + mise generate task-stubs | Out-Null + & '.\bin\echoer.cmd' extra | Select-Object -Last 1 | Should -BeLike '*extra*' + } + + It 'is idempotent' { + # Regeneration walks the existing stub tree and refuses anything it did not write, so the + # launchers have to be recognised as generated or the second run fails. + mise generate task-stubs | Out-Null + mise generate task-stubs | Out-Null + Test-Path 'bin\hello.cmd' | Should -BeTrue + & '.\bin\hello.cmd' | Select-Object -Last 1 | Should -Be 'hi-from-task' + } +} diff --git a/e2e-win/tool_stub.Tests.ps1 b/e2e-win/tool_stub.Tests.ps1 new file mode 100644 index 00000000000..f758ca57db3 --- /dev/null +++ b/e2e-win/tool_stub.Tests.ps1 @@ -0,0 +1,119 @@ +Describe 'tool-stub' { + BeforeAll { + $originalPath = Get-Location + # The test workflow sets MISE_TRUSTED_CONFIG_PATHS for the whole job, and Pester runs every + # suite in one process, so dropping it here would take it away from the files that run + # after this one. Put back what was there instead. + $originalTrustedConfigPaths = [Environment]::GetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', 'Process') + Set-Location TestDrive: + $env:MISE_TRUSTED_CONFIG_PATHS = $TestDrive + + # Shared here rather than built up across It blocks, so any one of them can be run alone + # with `run.ps1 -TestName`. + @' +#!/usr/bin/env -S mise tool-stub +tool = "aqua:jqlang/jq" +version = "1.7.1" +bin = "jq" +'@ | Out-File -FilePath 'jqstub' -Encoding utf8NoBOM + mise generate tool-stub jqstub --fetch | Out-Null + } + + AfterAll { + Set-Location $originalPath + if ($null -eq $originalTrustedConfigPaths) { + Remove-Item -Path Env:\MISE_TRUSTED_CONFIG_PATHS -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', $originalTrustedConfigPaths, 'Process') + } + } + + It 'writes a .cmd launcher beside the stub' { + # Windows cannot execute the stub itself -- it is a shebang script, which CreateProcess + # refuses -- so the generator has to leave something Windows will launch. + mise generate tool-stub plainstub --url 'https://example.com/thing.tar.gz' --skip-download | Out-Null + Test-Path 'plainstub' | Should -BeTrue + Test-Path 'plainstub.cmd' | Should -BeTrue + # %~dpn0, not the stub name: the launcher derives the path from its own filename, which is + # what keeps a name containing `%` from being mangled by cmd.exe. + (Get-Content 'plainstub.cmd' -Raw) | Should -BeLike '*mise tool-stub "%~dpn0" %`**' + } + + It 'omits the launcher for a stub that does not ship for Windows' { + # The counterpart to the test above: without this one, a launcher written unconditionally + # would look just as correct. + mise generate tool-stub unixonly --skip-download ` + --platform-url 'linux-x64:https://example.com/tool-linux.tar.gz' ` + --platform-url 'macos-arm64:https://example.com/tool-macos.tar.gz' | Out-Null + Test-Path 'unixonly' | Should -BeTrue + Test-Path 'unixonly.cmd' | Should -BeFalse + } + + It 'writes the launcher when the stub names a Windows platform' { + mise generate tool-stub crossplat --skip-download ` + --platform-url 'linux-x64:https://example.com/tool-linux.tar.gz' ` + --platform-url 'windows-x64:https://example.com/tool-windows.zip' | Out-Null + Test-Path 'crossplat.cmd' | Should -BeTrue + } + + It 'runs a real tool through the generated launcher' { + # The point of the whole change: the stub is launchable from PowerShell/cmd. Uses a real + # tool rather than a dummy URL so this exercises install + execute, not just file creation. + Test-Path 'jqstub.cmd' | Should -BeTrue + & '.\jqstub.cmd' --version | Select-Object -Last 1 | Should -BeLike 'jq-1.7.1*' + } + + It 'gives the same result through mise tool-stub directly' { + # The explicit invocation already worked before this change; keep it that way. + mise tool-stub jqstub --version | Select-Object -Last 1 | Should -BeLike 'jq-1.7.1*' + } + + It 'runs a stub whose name contains a percent sign' { + # A batch file drops `%x` when x is not a parameter, so a launcher that spelled the stub + # name out would compute `jqstub` for this file and fail to find it. Deriving the path from + # the launcher's own filename with %~dpn0 sidesteps that. + # + # One `%`, not a `%VAR%` pair: cmd.exe expands pairs in the path it is handed, so a stub + # named `jq%PATH%stub` cannot be launched at all, whatever its launcher contains. + @' +#!/usr/bin/env -S mise tool-stub +tool = "aqua:jqlang/jq" +version = "1.7.1" +bin = "jq" +'@ | Out-File -FilePath 'jq%stub' -Encoding utf8NoBOM + mise generate tool-stub 'jq%stub' --fetch | Out-Null + Test-Path 'jq%stub.cmd' | Should -BeTrue + + & '.\jq%stub.cmd' --version | Select-Object -Last 1 | Should -BeLike 'jq-1.7.1*' + } + + It 'removes its own launcher when the stub stops shipping for Windows' { + mise generate tool-stub transition --skip-download ` + --platform-url 'windows-x64:https://example.com/tool-windows.zip' | Out-Null + Test-Path 'transition.cmd' | Should -BeTrue + + # Rewrite the same stub as unix-only, then regenerate through the same code path. + @' +#!/usr/bin/env -S mise tool-stub + +[platforms.linux-x64] +url = "https://example.com/tool-linux.tar.gz" +'@ | Out-File -FilePath 'transition' -Encoding utf8NoBOM + mise generate tool-stub transition --skip-download ` + --platform-url 'linux-x64:https://example.com/tool-linux.tar.gz' | Out-Null + + Test-Path 'transition.cmd' | Should -BeFalse + } + + It 'leaves a launcher it did not write alone' { + mise generate tool-stub handwritten --skip-download ` + --platform-url 'linux-x64:https://example.com/tool-linux.tar.gz' | Out-Null + 'echo mine' | Out-File -FilePath 'handwritten.cmd' -Encoding utf8NoBOM + + mise generate tool-stub handwritten --skip-download ` + --platform-url 'linux-x64:https://example.com/tool-linux.tar.gz' | Out-Null + + Test-Path 'handwritten.cmd' | Should -BeTrue + (Get-Content 'handwritten.cmd' -Raw) | Should -BeLike '*echo mine*' + } +} diff --git a/e2e/generate/test_generate_task_stubs b/e2e/generate/test_generate_task_stubs index f2f1056ee91..c184e05f3a7 100644 --- a/e2e/generate/test_generate_task_stubs +++ b/e2e/generate/test_generate_task_stubs @@ -4,6 +4,18 @@ assert "mise tasks add xxx -- echo 'running xxx'" assert "mise generate task-stubs" assert "./bin/xxx" "running xxx" +# Windows cannot execute the `#!/bin/sh` stub, so a launcher is written beside it. Generated here +# too, not only on Windows: stubs are committed, and the contributor who runs one on Windows is not +# the person who generated it. +assert_succeed "test -f bin/xxx.cmd" +# Both words are quoted, and for the same reason: each is interpolated into a cmd line, where a +# space splits it and `&` would end the command outright. Quoting is unconditional -- a quoted bare +# name still resolves through PATH, so there is no case where it costs anything. +assert_contains "cat bin/xxx.cmd" '"mise" run "xxx" %*' +# What marks the file as ours. Regeneration and cleanup both key off this rather than the shape of +# the command line, so that a hand-written batch file is never mistaken for a generated one. +assert_contains "cat bin/xxx.cmd" "rem generated by mise" + mkdir -p .mise/tasks/work/containers cat >.mise/tasks/work/containers/_default <<'EOF' #!/bin/sh @@ -26,7 +38,15 @@ test -x bin/work/containers/redis assert "bin/work/containers/_default" "parent" assert "bin/work/containers/pg" "pg" assert "bin/work/containers/redis" "redis" + +# Launchers land inside nested stub directories as well. +assert_succeed "test -f bin/work/containers/pg.cmd" +assert_succeed "test -f bin/work/containers/_default.cmd" + +# Regenerating walks that directory and refuses anything it did not write, so the launchers have to +# be recognised as generated -- without that, this second run fails. assert "mise generate task-stubs" +assert "bin/work/containers/pg" "pg" cat >.mise/tasks/legacy <<'EOF' #!/bin/sh @@ -59,6 +79,11 @@ test -x legacy-bin/legacy/child assert "legacy-bin/legacy/_default" "legacy" assert "legacy-bin/legacy/child" "child" +# The launcher goes with the stub it belonged to. Left behind, `legacy-bin/legacy.cmd` would keep +# running a task whose stub now lives at `legacy-bin/legacy/_default`. +test ! -e legacy-bin/legacy.cmd +test -f legacy-bin/legacy/_default.cmd + # Removing the nested task collapses the generated directory back to a leaf stub. cat >legacy-bin/legacy/user-script <<'EOF' #!/bin/sh @@ -96,6 +121,17 @@ assert "cat symlink-target" "user-owned" test -L symlink-bin/xxx test ! -e symlink-bin/work +# `bin/.cmd` is a plausible name for a script a project already keeps, and nothing about the +# name says mise owns it. Generation stops rather than overwriting one, and stops during validation, +# so no stub is written either. +mkdir -p owned-bin +printf '@echo off\r\necho mine\r\n' >owned-bin/xxx.cmd +assert_fail_contains \ + "mise generate task-stubs --dir owned-bin" \ + "owned-bin/xxx.cmd is not a generated launcher" +assert_contains "cat owned-bin/xxx.cmd" "echo mine" +test ! -e owned-bin/xxx + mkdir -p relocated-blocked-bin/work/containers cat >relocated-blocked-bin/work/containers/_default <<'EOF' #!/bin/sh diff --git a/e2e/generate/test_generate_tool_stub b/e2e/generate/test_generate_tool_stub index c5b363fdbe6..f13093ad18c 100644 --- a/e2e/generate/test_generate_tool_stub +++ b/e2e/generate/test_generate_tool_stub @@ -187,4 +187,45 @@ assert_contains "cat hello-stub" "size =" assert_contains "cat gh-test-stub" "checksum =" assert_contains "cat gh-test-stub" "size =" +# Test 5: a Windows launcher is written beside stubs that could run on Windows. +# Windows cannot execute the shebang stub itself, and the stub is usually authored on Unix and +# committed, so the launcher has to be produced here rather than on the Windows machine. + +# No platforms named at all -> nothing was ruled out, so the launcher is written. +# It derives the stub path from its own filename rather than naming it, so a stub name containing +# `%` cannot be expanded by cmd.exe into the wrong path. +assert_succeed "test -f hello-stub.cmd" +assert_contains "cat hello-stub.cmd" 'mise tool-stub "%~dpn0" %*' + +# Explicitly cross-platform including windows -> written. +assert_succeed "mise generate tool-stub cross-stub --skip-download \ + --platform-url linux-x64:'https://example.com/tool-linux.tar.gz' \ + --platform-url windows-x64:'https://example.com/tool-windows.zip'" +assert_succeed "test -f cross-stub.cmd" + +# Ships for unix only -> no launcher. Without this the check above would pass even if the +# launcher were written unconditionally. +assert_succeed "mise generate tool-stub unix-only-stub --skip-download \ + --platform-url linux-x64:'https://example.com/tool-linux.tar.gz' \ + --platform-url macos-arm64:'https://example.com/tool-macos.tar.gz'" +assert_fail "test -f unix-only-stub.cmd" + +# A stub that stops shipping for Windows loses the launcher, which would otherwise keep working and +# contradict the platforms the stub now declares. +cat <<'EOF' >cross-stub +#!/usr/bin/env -S mise tool-stub + +[platforms.linux-x64] +url = "https://example.com/tool-linux.tar.gz" +EOF +assert_succeed "mise generate tool-stub cross-stub --skip-download \ + --platform-url linux-x64:'https://example.com/tool-linux.tar.gz'" +assert_fail "test -f cross-stub.cmd" + +# ...but a launcher mise did not write is left where it is. +echo 'echo mine' >unix-only-stub.cmd +assert_succeed "mise generate tool-stub unix-only-stub --skip-download \ + --platform-url linux-x64:'https://example.com/tool-linux.tar.gz'" +assert_contains "cat unix-only-stub.cmd" "echo mine" + echo "All slow tool-stub generation tests passed!" diff --git a/src/cli/generate/mod.rs b/src/cli/generate/mod.rs index 0f6710440a6..cf505bac005 100644 --- a/src/cli/generate/mod.rs +++ b/src/cli/generate/mod.rs @@ -1,4 +1,5 @@ use clap::Subcommand; +use std::path::{Path, PathBuf}; mod bootstrap; mod config; @@ -49,3 +50,162 @@ impl Generate { 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 { + 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}.cmd"))) +} + +/// 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. +/// +/// `%*` forwards the argument text with quoting intact, and cmd returns the last command's exit +/// code when the script ends. +pub(super) fn windows_launcher_body(command: &str) -> String { + format!("@echo off\r\n{WINDOWS_LAUNCHER_MARKER}\r\n{command} %*\r\n") +} + +/// 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`], never on comparing the body. The body 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 would +/// call it a stranger and leave it behind. The rest of the shape is checked so that +/// `@echo off` plus something ending in ` %*` is not enough on its own: the consequence of a false +/// positive here is deleting a file mise did not write. +pub(super) fn is_generated_launcher(contents: &str) -> bool { + let mut lines = contents.lines(); + matches!(lines.next(), Some("@echo off")) + && lines.next() == Some(WINDOWS_LAUNCHER_MARKER) + && lines.next().is_some_and(|line| line.ends_with(" %*")) + && lines.next().is_none() +} + +/// 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}"); + } + } + + #[test] + fn body_forwards_arguments() { + assert_eq!( + windows_launcher_body("mise run hello"), + "@echo off\r\nrem generated by mise\r\nmise run hello %*\r\n" + ); + } + + #[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_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%%\""); + } +} diff --git a/src/cli/generate/task_stubs.rs b/src/cli/generate/task_stubs.rs index c639ffe8cb2..90575492c3e 100644 --- a/src/cli/generate/task_stubs.rs +++ b/src/cli/generate/task_stubs.rs @@ -46,6 +46,7 @@ impl TaskStubs { path, output: self.generate(task)?, legacy_output: self.generate_legacy(task)?, + launcher: self.generate_launcher(task), }) }) .collect::>>()?; @@ -53,21 +54,46 @@ impl TaskStubs { for migration in migrations { match migration { - StubMigration::File(path) => file::remove_file(path)?, + StubMigration::File(path) => { + // The launcher goes with the stub it belongs to, or it keeps running a task + // that no longer has a stub here. Only one mise wrote is removed, so a + // hand-written .cmd is left alone. + remove_generated_launcher(&path)?; + file::remove_file(path)? + } StubMigration::Directory(path) => file::remove_all(path)?, } } - for stub in stubs { + for stub in &stubs { if let Some(parent) = stub.path.parent() { file::create_dir_all(parent)?; } file::write(&stub.path, &stub.output)?; file::make_executable(&stub.path)?; + // Windows will not execute the `#!/bin/sh` stub, so it needs something it can launch. + // Written on every host: stubs are committed, and the contributor who runs one on + // Windows is not the person who generated it. + if let Some(launcher_path) = super::windows_launcher_path(&stub.path) { + file::write(&launcher_path, &stub.launcher)?; + } miseprintln!("Wrote to {}", display_path(&stub.path)); } Ok(()) } + /// The Windows launcher body for `task`, mirroring what the stub itself runs. + /// + /// `mise_bin` is embedded as given: with the default `mise` it resolves off PATH, and a + /// `--mise-bin` pointing at a `mise generate bootstrap` script will start working here as soon + /// as that script gains a Windows form of its own. + fn generate_launcher(&self, task: &Task) -> String { + let mise_bin = super::cmd_quote(&self.mise_bin.to_string_lossy()); + // The task name goes through the same quoting: it is interpolated into the same cmd line, + // so a `&` or `%` in it breaks the launcher exactly the way one in the path does. + let display_name = super::cmd_quote(&task.display_name); + super::windows_launcher_body(&format!("{mise_bin} run {display_name}")) + } + fn generate(&self, task: &Task) -> Result { let mise_bin = self.mise_bin.to_string_lossy(); let mise_bin = shell_words::quote(&mise_bin); @@ -102,6 +128,27 @@ struct TaskStub<'a> { path: PathBuf, output: String, legacy_output: String, + launcher: String, +} + +/// Remove the Windows launcher beside a stub that is being migrated away. +/// +/// Recognised by [`super::is_generated_launcher`] rather than by comparing against the launchers +/// this run would produce. Those bodies embed `--mise-bin` and the task name, so a run that changes +/// either would not recognise the launcher it wrote last time and would leave `.cmd` behind, +/// still runnable and still pointing at the old mise. Anything mise did not write stays put, and a +/// missing file is fine: most stubs never had one. +fn remove_generated_launcher(stub_path: &Path) -> Result<()> { + let Some(launcher) = super::windows_launcher_path(stub_path) else { + return Ok(()); + }; + let Ok(existing) = file::read_to_string(&launcher) else { + return Ok(()); + }; + if super::is_generated_launcher(&existing) { + file::remove_file(&launcher)?; + } + Ok(()) } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -193,6 +240,7 @@ fn validate_stub_paths(dir: &Path, stubs: &[TaskStub<'_>]) -> Result {} Err(err) => return Err(err.into()), } + validate_launcher_path(stub)?; for parent in stub.path.ancestors().skip(1) { match fs::symlink_metadata(parent) { Ok(metadata) @@ -214,6 +262,35 @@ fn validate_stub_paths(dir: &Path, stubs: &[TaskStub<'_>]) -> Result.cmd` is a name a project may already be +/// using for a script of its own — and unlike the stub itself, nothing about the name says mise +/// owns it. Checked during validation rather than at the write, so a launcher that is not ours +/// stops the whole run instead of leaving a half-generated `bin/`. +fn validate_launcher_path(stub: &TaskStub<'_>) -> Result<()> { + let Some(launcher) = super::windows_launcher_path(&stub.path) else { + return Ok(()); + }; + match fs::symlink_metadata(&launcher) { + Ok(metadata) if metadata.file_type().is_file() => { + if !super::is_generated_launcher(&file::read_to_string(&launcher)?) { + bail!( + "cannot write Windows launcher because {} is not a generated launcher", + display_path(&launcher) + ); + } + } + Ok(_) => bail!( + "cannot write Windows launcher because {} is not a regular file", + display_path(&launcher) + ), + Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {} + Err(err) => return Err(err.into()), + } + Ok(()) +} + fn validate_generated_stub_directory(path: &Path, expected: &str, task: &Task) -> Result<()> { let default = path.join("_default"); match fs::symlink_metadata(&default) { @@ -249,6 +326,11 @@ fn validate_generated_stub_tree(path: &Path) -> Result { && is_generated_task_stub(&file::read_to_string(&entry_path)?) { files += 1; + } else if metadata.file_type().is_file() + && super::is_generated_launcher(&file::read_to_string(&entry_path)?) + { + // Our own Windows launcher. Not counted towards `files`: a directory holding nothing + // but launchers has no stubs left and should still be reported as empty. } else { bail!( "cannot replace task stub directory because {} is not a generated task stub", diff --git a/src/cli/generate/tool_stub.rs b/src/cli/generate/tool_stub.rs index eee27491a1d..656f2ad84bc 100644 --- a/src/cli/generate/tool_stub.rs +++ b/src/cli/generate/tool_stub.rs @@ -20,7 +20,10 @@ use color_eyre::eyre::bail; use indexmap::IndexMap; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::ErrorKind; use std::path::PathBuf; +use std::sync::LazyLock; use toml_edit::DocumentMut; /// Generate a tool stub for HTTP-based tools @@ -154,12 +157,18 @@ impl ToolStub { self.generate_stub().await? }; + // Before the stub is written, so a launcher that is not ours stops the run rather than + // leaving a regenerated stub whose launcher was silently skipped. Same ordering, and the + // same reason, as `task-stubs`. + self.validate_windows_launcher(&stub_content)?; + if let Some(parent) = self.output.parent() { file::create_dir_all(parent)?; } file::write(&self.output, &stub_content)?; file::make_executable(&self.output)?; + self.write_windows_launcher(&stub_content)?; if self.fetch || self.lock { miseprintln!("Updated tool stub: {}", display_path(&self.output)); @@ -169,6 +178,63 @@ impl ToolStub { Ok(()) } + /// Write the `.cmd` next to a stub that could run on Windows. + /// + /// Windows cannot execute the stub itself: it is a shebang script, and `CreateProcess` will not + /// run one. The stub's own machinery works there — `mise tool-stub ` does the right thing + /// — so all that is missing is something Windows will launch. + /// + /// Written on every host, not just Windows, because the case worth fixing is a stub authored on + /// Linux and committed: the Windows user who runs it is not its author and has no way to + /// regenerate it (they do not have the original `--url`/`--version`). A host-OS check would + /// never fire for them. + fn write_windows_launcher(&self, stub_content: &str) -> Result<()> { + let Some(launcher) = super::windows_launcher_path(&self.output) else { + return Ok(()); + }; + if wants_windows_launcher(stub_content) { + file::write(&launcher, &*WINDOWS_LAUNCHER)?; + } else if file::read_to_string(&launcher).is_ok_and(|c| super::is_generated_launcher(&c)) { + // A stub that used to ship for Windows and no longer does would otherwise keep a + // launcher that still runs, contradicting the platforms it now declares. Recognised by + // the marker rather than by comparing text, so a launcher written by an older mise -- + // whose body differed -- is still cleaned up; a launcher the user wrote is left alone. + file::remove_file(&launcher)?; + } + Ok(()) + } + + /// Refuse to replace a `.cmd` beside the stub that mise did not write. + /// + /// `--output` is the user's choice, so `.cmd` is a name a project may already be using + /// for a script of its own, and nothing about that name says mise owns it. The stub itself is + /// different: the user named it, so overwriting it is what they asked for. + fn validate_windows_launcher(&self, stub_content: &str) -> Result<()> { + if !wants_windows_launcher(stub_content) { + return Ok(()); + } + let Some(launcher) = super::windows_launcher_path(&self.output) else { + return Ok(()); + }; + match fs::symlink_metadata(&launcher) { + Ok(metadata) if metadata.file_type().is_file() => { + if !super::is_generated_launcher(&file::read_to_string(&launcher)?) { + bail!( + "cannot write Windows launcher because {} is not a generated launcher", + display_path(&launcher) + ); + } + } + Ok(_) => bail!( + "cannot write Windows launcher because {} is not a regular file", + display_path(&launcher) + ), + Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {} + Err(err) => return Err(err.into()), + } + Ok(()) + } + fn get_tool_name(&self) -> String { self.output .file_name() @@ -887,6 +953,53 @@ fn extract_toml_from_stub(content: &str) -> String { } } +/// The Windows launcher written beside an eligible stub. +/// +/// `%~dpn0` is the launcher's own drive+path+name with the extension dropped, which is exactly the +/// stub sitting next to it — so the stub's name never has to be interpolated in. That matters +/// beyond tidiness: a name containing `%` would otherwise be expanded by `cmd.exe` as an +/// environment variable and hand `mise tool-stub` the wrong path. +/// +/// `%*` forwards the argument text with quoting intact, and cmd returns the last command's exit +/// code when the script ends. `mise` comes off PATH, the same assumption the shebang already makes. +/// +/// Being one fixed string also makes it safe to clean up: an existing `.cmd` is only removed when +/// it matches this exactly, so a hand-written launcher is never deleted. +static WINDOWS_LAUNCHER: LazyLock = + LazyLock::new(|| super::windows_launcher_body("mise tool-stub \"%~dpn0\"")); + +/// Whether a stub warrants a Windows `.cmd` launcher. +/// +/// Judged from the stub itself rather than the host OS — see [`ToolStub::write_windows_launcher`] +/// for why. A stub earns one unless its author ruled Windows out: +/// +/// - lists a `[platforms.*]` table with a windows entry → yes +/// - lists `[platforms.*]` tables but none for windows → no, this tool does not ship for Windows +/// - lists no platforms at all (`tool = "python"`, or a bare `url`) → yes, nothing was ruled out +/// +/// The stub's *name* is judged separately, by [`super::windows_launcher_path`]. +fn wants_windows_launcher(stub_content: &str) -> bool { + // Via the same extractor the rest of this file uses, so a --bootstrap stub (TOML embedded in a + // bash wrapper) is read correctly rather than failing to parse and falling through. + let Ok(doc) = extract_toml_from_stub(stub_content).parse::() else { + // Unparseable stubs are the generator's problem, not this function's; err towards the + // launcher so a Windows user is not silently left without one. + return true; + }; + let Some(platforms) = doc.get("platforms").and_then(|p| p.as_table_like()) else { + return true; + }; + if platforms.is_empty() { + return true; + } + // Parsed rather than prefix-matched: `windows-arm64` counts, and a future qualifier such as + // `windows-x64-msvc` keeps counting. + platforms + .iter() + .filter_map(|(key, _)| Platform::parse(key).ok()) + .any(|p| p.os == "windows") +} + static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: @@ -959,3 +1072,55 @@ mod tests { ); } } + +#[cfg(test)] +mod windows_launcher_tests { + use super::*; + + #[test] + fn a_stub_that_ships_for_windows_gets_a_launcher() { + let stub = r#" +[platforms.linux-x64] +url = "https://example.com/tool-linux.tar.gz" + +[platforms.windows-x64] +url = "https://example.com/tool-windows.zip" +bin = "tool.exe" +"#; + assert!(wants_windows_launcher(stub)); + + // arm64-only windows counts too — this is why the check parses the platform instead of + // looking for the string "windows-x64" + let arm_only = r#" +[platforms.windows-arm64] +url = "https://example.com/tool-windows-arm64.zip" +"#; + assert!(wants_windows_launcher(arm_only)); + } + + #[test] + fn a_stub_with_no_windows_platform_does_not() { + let stub = r#" +[platforms.linux-x64] +url = "https://example.com/tool-linux.tar.gz" + +[platforms.macos-arm64] +url = "https://example.com/tool-macos.tar.gz" +"#; + assert!(!wants_windows_launcher(stub)); + } + + #[test] + fn a_stub_that_names_no_platforms_gets_one() { + // nothing was ruled out, and these run anywhere mise does + assert!(wants_windows_launcher( + "version = \"1.0.0\"\ntool = \"python\"\n" + )); + assert!(wants_windows_launcher( + "url = \"https://example.com/thing.tar.gz\"\n" + )); + } + + // The executable-extension guard lives in `super::windows_launcher_path` now, and is tested + // there — both stub kinds share it. +}