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
11 changes: 11 additions & 0 deletions docs/tasks/file-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ cargo build

Adding `#!/usr/bin/env bash` is usually all it takes, and it costs nothing on the other platforms.

### PowerShell tasks with no `.ps1` extension

Windows PowerShell refuses to open a script whose name does not end in `.ps1` — a rule the Linux
and macOS builds do not have. So that a `#!/usr/bin/env pwsh` task behaves the same everywhere,
mise runs it from a `.ps1` copy in the temp directory and removes the copy when the task finishes.

Only the script's view of its own location changes: `$PSScriptRoot` and `$PSCommandPath` name the
copy rather than the task file. The working directory, `$args`, and the environment are untouched.
A task that needs to find files sitting next to it should carry a `.ps1` extension, which is run
in place.

### Writing one task for both platforms

File tasks have no equivalent of a TOML task's
Expand Down
131 changes: 131 additions & 0 deletions e2e-win/task_pwsh_shebang.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
Describe 'file tasks whose shebang names pwsh' {
BeforeAll {
$script:OriginalDir = Get-Location
# Saved here, restored in AfterAll: Pester runs every suite in one process, so removing an
# inherited value would leave the next suite without it.
$script:OriginalTrusted = [Environment]::GetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', 'Process')
$script:TestRoot = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path (Join-Path $script:TestRoot "mise-tasks") | Out-Null
$env:MISE_TRUSTED_CONFIG_PATHS = $script:TestRoot
"[tools]" | Out-File -Encoding ascii (Join-Path $script:TestRoot "mise.toml")

function Write-Task {
param([string]$Name, [string]$Body)
$path = Join-Path $script:TestRoot "mise-tasks\$Name"
# LF, because this file's own line endings are CRLF: a stray `r would ride along
# inside the bash body below and reach the interpreter as part of the command.
$lf = $Body -replace "`r`n", "`n"
[System.IO.File]::WriteAllText($path, $lf, (New-Object System.Text.UTF8Encoding $false))
}

# Extensionless plus a pwsh shebang -- the shape docs/tasks/file-tasks.md shows under
# "Shebang". Windows PowerShell rejects `-File` on anything not named `*.ps1`, so mise
# has to hand it a name pwsh will accept. The Linux build has no such rule, which is why
# this only ever failed here.
Write-Task -Name "pwsh_task" -Body @'
#!/usr/bin/env pwsh
Write-Output "pwsh ran: $($args[0])"
'@

# Control: found by extension, so it never needed the shim. It pins that the path which
# already worked still works.
Write-Task -Name "pwsh_ext.ps1" -Body @'
#!/usr/bin/env pwsh
Write-Output "ext ran: $($args[0])"
'@

# An explicit `shell` overrides the shebang, and PowerShell's *command* resolution wants
# `.ps1` exactly as its `-File` handling does. Left out, this one exits 0 having printed
# nothing at all.
Write-Task -Name "pwsh_command_mode" -Body @'
#!/usr/bin/env pwsh
#MISE shell="pwsh -c"
Write-Output "command mode ran: $($args[0])"
'@

# Control: nothing outside PowerShell should change. `printf` rather than `echo` so this
# can only pass through bash -- cmd has no `printf`.
Write-Task -Name "bash_task" -Body @'
#!/usr/bin/env bash
printf 'bash ran: %s\n' "$1"
'@

# A batch file, for the case where the file shell resolves to PowerShell but the task
# needs no help: PowerShell runs `.cmd` as a native command. Copying it under a `.ps1`
# name would hand batch syntax to the PowerShell parser instead.
Write-Task -Name "cmd_task.cmd" -Body @'
@echo off
echo cmd task ran: %1
'@

Set-Location $script:TestRoot
}

AfterAll {
Set-Location $script:OriginalDir
if ($null -eq $script:OriginalTrusted) {
Remove-Item Env:MISE_TRUSTED_CONFIG_PATHS -ErrorAction Ignore
} else {
[Environment]::SetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', $script:OriginalTrusted, 'Process')
}
}

It 'runs an extensionless task whose shebang names pwsh' {
$output = mise run pwsh_task ARG1 | Out-String
$output | Should -Match "pwsh ran: ARG1"
}

It 'runs a pwsh task whose shell is in -Command mode' {
# Asserted on what the task printed, not on the exit code: this one failed by exiting 0
# and doing nothing, so an exit-code check passes against the bug.
$output = mise run pwsh_command_mode ARG1 | Out-String
$output | Should -Match "command mode ran: ARG1"
}

It 'still runs the .ps1 sibling' {
$output = mise run pwsh_ext ARG1 | Out-String
$output | Should -Match "ext ran: ARG1"
}

It 'still runs a bash shebang task' {
$output = mise run bash_task ARG1 | Out-String
$output | Should -Match "bash ran: ARG1"
}

It 'leaves a batch task alone when the file shell is PowerShell' {
# `use_file_shell_for_executable_tasks` sends a `.cmd` through the file shell, which here
# is PowerShell -- and PowerShell runs a `.cmd` as a native command, so it needs no
# rename. Staging it as `.ps1` would give the parser `@echo off`, which fails while still
# exiting 0, so this asserts on the output.
$saved = @{
UseShell = [Environment]::GetEnvironmentVariable('MISE_USE_FILE_SHELL_FOR_EXECUTABLE_TASKS', 'Process')
FileArgs = [Environment]::GetEnvironmentVariable('MISE_WINDOWS_DEFAULT_FILE_SHELL_ARGS', 'Process')
}
try {
$env:MISE_USE_FILE_SHELL_FOR_EXECUTABLE_TASKS = '1'
$env:MISE_WINDOWS_DEFAULT_FILE_SHELL_ARGS = 'pwsh -c'
$output = mise run cmd_task ARG1 | Out-String
$output | Should -Match "cmd task ran: ARG1"
} finally {
foreach ($pair in @(
@('MISE_USE_FILE_SHELL_FOR_EXECUTABLE_TASKS', $saved.UseShell),
@('MISE_WINDOWS_DEFAULT_FILE_SHELL_ARGS', $saved.FileArgs))) {
if ($null -eq $pair[1]) {
Remove-Item ("Env:" + $pair[0]) -ErrorAction Ignore
} else {
[Environment]::SetEnvironmentVariable($pair[0], $pair[1], 'Process')
}
}
}
}

It 'leaves no copy behind in the temp directory' {
# The copy exists only for the length of the run. A leak would deposit one file per
# invocation, in a directory nothing else cleans.
$pattern = 'mise-task-*.ps1'
$before = @(Get-ChildItem -Path $env:TEMP -Filter $pattern -ErrorAction Ignore).Count
mise run pwsh_task ARG1 | Out-Null
$after = @(Get-ChildItem -Path $env:TEMP -Filter $pattern -ErrorAction Ignore).Count
$after | Should -Be $before
}
}
121 changes: 107 additions & 14 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,30 +1242,28 @@ impl TaskExecutor {
))
}

/// Assemble `(program, args)` for running `file` under an already-resolved `shell`.
///
/// The shell arrives resolved rather than being worked out here, because `file` may be a
/// [`ps1_shim`] copy: resolving from the copy would let [`shell_from_extension`] answer
/// `pwsh -File` where the original had taken its shell from somewhere else.
fn get_file_program_and_args(
&self,
file: &Path,
task: &Task,
shell: &[String],
args: &[String],
) -> Result<(String, Vec<String>)> {
let display = file.display().to_string();
if !Settings::get().use_file_shell_for_executable_tasks && can_execute_directly(file) {
return Ok((display, args.to_vec()));
}
let mut shell = task
.shell()?
.or_else(|| shell_from_shebang(file))
.or_else(|| shell_from_extension(file))
.unwrap_or(Settings::get().default_file_shell()?);
let mut shell = shell.to_vec();
Settings::get().maybe_no_profile(&mut shell);
let (program, _) = task_shell_parts(&shell, "file shell")?;
let program = program.to_string();
trace!("using shell: {}", shell.join(" "));
let mut full_args = shell.to_vec();
full_args.push(display);
let mut full_args = shell;
full_args.push(file.display().to_string());
if !args.is_empty() {
full_args.extend(args.iter().cloned());
}
Ok((program.to_string(), full_args[1..].to_vec()))
Ok((program, full_args[1..].to_vec()))
}

/// Build the `(program, args, cmd_verbatim)` for an inline script. When
Expand Down Expand Up @@ -1449,7 +1447,17 @@ impl TaskExecutor {
}

async fn exec(&self, file: &Path, args: &[String], ctx: TaskExecContext<'_>) -> Result<()> {
let (program, args) = self.get_file_program_and_args(file, ctx.task, args)?;
if runs_without_a_shell(file) {
let program = file.display().to_string();
return self.exec_program(&program, args, false, ctx).await;
}
// Resolved once, from the file the user wrote, and then used for both decisions below.
let shell = file_task_shell(file, ctx.task)?;
// Held, not dropped: the copy has to outlive the spawn below, and binding it here
// is what keeps it on disk for exactly that long. See [`ps1_shim`].
let shim = ps1_shim(file, &shell)?;
let script = shim.as_deref().unwrap_or(file);
let (program, args) = self.get_file_program_and_args(script, &shell, args)?;
self.exec_program(&program, &args, false, ctx).await
}

Expand Down Expand Up @@ -2289,6 +2297,91 @@ fn shell_from_extension(path: &Path) -> Option<Vec<String>> {
}
}

/// True when mise hands the file straight to the OS rather than starting a shell for it,
/// in which case no shell resolution happens and nothing below applies.
fn runs_without_a_shell(file: &Path) -> bool {
!Settings::get().use_file_shell_for_executable_tasks && can_execute_directly(file)
}

/// The shell a file task runs under, in precedence order: the task's explicit `shell`,
/// then the shebang, then the extension, then the default file shell.
///
/// Called once per run, before the command line is assembled, and the answer is then used
/// for both [`ps1_shim`] and [`TaskExecutor::get_file_program_and_args`] — asking a second
/// time from a shim copy would not give the same answer.
fn file_task_shell(file: &Path, task: &Task) -> Result<Vec<String>> {
Ok(task
.shell()?
.or_else(|| shell_from_shebang(file))
.or_else(|| shell_from_extension(file))
.unwrap_or(Settings::get().default_file_shell()?))
}

/// A `.ps1`-named copy of `file`, for the one case where PowerShell will not run the
/// original: on **Windows**, `pwsh` rejects a script whose name does not end in `.ps1`.
///
/// The Linux and macOS builds have no such rule — measured on the same pwsh 7.6.5, an
/// extensionless script runs there and is refused here — so `#!/usr/bin/env pwsh`, the
/// shape the file-task docs show, worked everywhere except Windows. Windows has no kernel
/// shebang either, so mise is the one starting the interpreter and the one that has to
/// hand it a name it will accept.
///
/// The copy lives exactly as long as the returned [`tempfile::TempPath`], which deletes it
/// on drop — the caller has to hold it across the spawn. `None` means the original is
/// runnable as it stands, which is every case off Windows.
///
/// The script sees the copy's path in `$PSCommandPath`/`$PSScriptRoot`. `$args` and the
/// working directory are unaffected.
fn ps1_shim(file: &Path, shell: &[String]) -> Result<Option<tempfile::TempPath>> {
#[cfg(windows)]
{
if needs_ps1_shim(file, shell) {
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("task");
let path = tempfile::Builder::new()
.prefix(&format!("mise-task-{stem}-"))
.suffix(".ps1")
.tempfile()?
.into_temp_path();
// Overwrites the empty file `tempfile()` just created under a name nothing
// else can have claimed, rather than picking a name and hoping.
std::fs::copy(file, &path)
.wrap_err_with(|| format!("failed to stage {} for pwsh", display_path(file)))?;
return Ok(Some(path));
}
}
#[cfg(not(windows))]
{
let _ = (file, shell);
}
Ok(None)
}

/// Whether [`ps1_shim`] applies: PowerShell is going to resolve this path as a script, and
/// the name would make it refuse.
///
/// The shell's mode does not enter into it. `-File` opens the path; `-Command` resolves it
/// as a command — and PowerShell's *command* resolution asks for `.ps1` just as its `-File`
/// handling does, so an extensionless task under `shell = "pwsh -c"` exits 0 having quietly
/// done nothing. Both readings are fixed by giving it a name pwsh will take.
///
/// A name the OS itself can start is left alone. PowerShell resolves `foo.cmd` and `foo.exe`
/// as commands and runs them correctly, and renaming one to `.ps1` would instead feed a batch
/// file to the PowerShell parser — which fails *and* still exits 0. That case is reachable
/// through `use_file_shell_for_executable_tasks` with a PowerShell default file shell.
#[cfg(windows)]
fn needs_ps1_shim(file: &Path, shell: &[String]) -> bool {
let already_runnable = file
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
ext.eq_ignore_ascii_case("ps1") || crate::file::os_can_launch_extension(ext)
});
!already_runnable
&& shell
.first()
.is_some_and(|program| crate::path::is_powershell_program(Path::new(program)))
}

fn task_shell_parts<'a>(shell: &'a [String], shell_kind: &str) -> Result<(&'a str, &'a [String])> {
shell
.split_first()
Expand Down
Loading