Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/dev-tools/tool-stubs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

### Via mise Command

Execute using the [`mise tool-stub`](/cli/tool-stub) command—useful for testing if something isn't working right:
Expand Down
11 changes: 7 additions & 4 deletions docs/tips-and-tricks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions e2e-win/task_stubs.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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'
}
}
119 changes: 119 additions & 0 deletions e2e-win/tool_stub.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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*'
}
}
36 changes: 36 additions & 0 deletions e2e/generate/test_generate_task_stubs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -96,6 +121,17 @@ assert "cat symlink-target" "user-owned"
test -L symlink-bin/xxx
test ! -e symlink-bin/work

# `bin/<task>.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
Expand Down
41 changes: 41 additions & 0 deletions e2e/generate/test_generate_tool_stub
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Loading
Loading