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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/latest/download/in
irm https://github.com/Devolutions/multi-pwsh/releases/latest/download/install-multi-pwsh.ps1 | iex
```

Install a specific tag (example `v0.14.0`):
Install a specific tag (example `v0.14.1`):

```bash
curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.0/install-multi-pwsh.sh | bash -s -- v0.14.0
curl -fsSL https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.1/install-multi-pwsh.sh | bash -s -- v0.14.1
```

```powershell
& ([scriptblock]::Create((irm https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.0/install-multi-pwsh.ps1))) -Version v0.14.0
& ([scriptblock]::Create((irm https://github.com/Devolutions/multi-pwsh/releases/download/v0.14.1/install-multi-pwsh.ps1))) -Version v0.14.1
```

Uninstall bootstrap scripts:
Expand Down Expand Up @@ -276,19 +276,19 @@ The current LTS line is encoded in the tool; at the moment that is `7.6`.
- Use `-mcp -McpCommands <command> [command ...]` to expose selected PowerShell commands as stdio MCP tools from the chosen hosted version.
- Use `multi-pwsh doctor --repair-aliases` to repair host shims and named aliases.

Advanced local replacement mode is also supported: if `multi-pwsh` is renamed to `pwsh`/`pwsh.exe` and placed beside `pwsh.dll` plus `pwsh.runtimeconfig.json`, it runs that adjacent payload directly from the executable directory instead of resolving the managed `pwsh` alias or searching `PATH`.
Advanced local replacement mode is also supported: if `multi-pwsh` is renamed to `pwsh`/`pwsh.exe`, it runs a local SDK payload directly instead of resolving the managed `pwsh` alias or searching `PATH`. The payload can be adjacent to the executable, or shared from the publish root when the executable is under `runtimes/<rid>/native/`.

See [docs/host-and-venv.md](docs/host-and-venv.md) for host shims, local replacement mode, venv layout, import/export, managed paths, and current limitations. See [docs/mcp.md](docs/mcp.md) for MCP host mode.

## CLI NuGet package and AppHost mode

`Devolutions.MultiPwsh.Cli` packages RID-specific `multi-pwsh` binaries under `runtimes/<rid>/native/` for .NET projects. It also exposes neutral MSBuild metadata for downstream packages that need to consume the same binaries as PowerShell apphosts. The package supplies only the native launcher; downstream packages must place it beside their own `pwsh.dll` and `pwsh.runtimeconfig.json`.
`Devolutions.MultiPwsh.Cli` packages RID-specific `multi-pwsh` binaries under `runtimes/<rid>/native/` for .NET projects. It also exposes neutral MSBuild metadata for downstream packages that need to consume the same binaries as PowerShell apphosts. The package supplies only the native launcher; downstream packages must provide their own `pwsh.dll` and `pwsh.runtimeconfig.json` either beside the renamed launcher or at the shared publish root above `runtimes/<rid>/native/`.

Package authors can consume the launchers privately and map them into their own package layout:

```xml
<ItemGroup>
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.0" PrivateAssets="all" />
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.1" PrivateAssets="all" />
</ItemGroup>

<PropertyGroup>
Expand All @@ -310,7 +310,7 @@ For a simple single-RID project, AppHost mode can copy the selected binary direc

```xml
<ItemGroup>
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.0" PrivateAssets="all" />
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.1" PrivateAssets="all" />
</ItemGroup>

<PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion crates/multi-pwsh/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "multi-pwsh"
version = "0.14.0"
version = "0.14.1"
edition = "2018"
license = "MIT/Apache-2.0"
homepage = "https://github.com/Devolutions/multi-pwsh"
Expand Down
129 changes: 112 additions & 17 deletions crates/multi-pwsh/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,21 @@ fn run_known_host_executable(
layout: Option<&InstallLayout>,
selector_input: &str,
pwsh_args: Vec<OsString>,
) -> Result<i32> {
let pwsh_dir = executable.parent().ok_or_else(|| {
MultiPwshError::Host(format!(
"failed to determine the PowerShell home directory from {}",
executable.display()
))
})?;
run_known_host_executable_for_pwsh_dir(pwsh_dir, layout, selector_input, pwsh_args)
}

fn run_known_host_executable_for_pwsh_dir(
pwsh_dir: &Path,
layout: Option<&InstallLayout>,
selector_input: &str,
pwsh_args: Vec<OsString>,
) -> Result<i32> {
let os = HostOs::detect()?;
let HostDispatchOptions { launch, mcp } = preprocess_host_args(pwsh_args)?;
Expand Down Expand Up @@ -893,7 +908,7 @@ fn run_known_host_executable(
));
}

return mcp::run_stdio_mcp_server(executable, &mcp.commands).map_err(|error| {
return mcp::run_stdio_mcp_server_for_pwsh_dir(pwsh_dir, &mcp.commands).map_err(|error| {
MultiPwshError::Host(format!(
"failed to start MCP host for selector '{}': {}",
selector_input, error
Expand All @@ -907,7 +922,7 @@ fn run_known_host_executable(
(pwsh_args, None)
};

pwsh_host::run_pwsh_command_line_for_pwsh_exe(executable, pwsh_args).map_err(|error| {
pwsh_host::run_pwsh_command_line_for_pwsh_dir(pwsh_dir, pwsh_args).map_err(|error| {
MultiPwshError::Host(format!(
"failed to start native host for selector '{}': {}",
selector_input, error
Expand Down Expand Up @@ -982,16 +997,48 @@ fn is_exact_pwsh_executable_name(executable_path: &Path) -> bool {
.unwrap_or(false)
}

fn is_local_pwsh_apphost(executable_path: &Path) -> bool {
fn has_pwsh_payload_markers(pwsh_dir: &Path) -> bool {
pwsh_dir.join("pwsh.dll").is_file() && pwsh_dir.join("pwsh.runtimeconfig.json").is_file()
}

fn path_file_name_eq_ignore_ascii_case(path: &Path, expected: &str) -> bool {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.eq_ignore_ascii_case(expected))
.unwrap_or(false)
}

fn resolve_runtime_native_apphost_payload_dir(native_dir: &Path) -> Option<PathBuf> {
if !path_file_name_eq_ignore_ascii_case(native_dir, "native") {
return None;
}

let rid_dir = native_dir.parent()?;
let runtimes_dir = rid_dir.parent()?;
if !path_file_name_eq_ignore_ascii_case(runtimes_dir, "runtimes") {
return None;
}

let shared_payload_dir = runtimes_dir.parent()?;
if has_pwsh_payload_markers(shared_payload_dir) {
return Some(shared_payload_dir.to_path_buf());
}

None
}

fn resolve_local_pwsh_apphost_payload_dir(executable_path: &Path) -> Option<PathBuf> {
if !is_exact_pwsh_executable_name(executable_path) {
return false;
return None;
}

let Some(executable_dir) = executable_path.parent() else {
return false;
};
let executable_dir = executable_path.parent()?;

executable_dir.join("pwsh.dll").is_file() && executable_dir.join("pwsh.runtimeconfig.json").is_file()
if has_pwsh_payload_markers(executable_dir) {
return Some(executable_dir.to_path_buf());
}

resolve_runtime_native_apphost_payload_dir(executable_dir)
}

fn infer_layout_from_host_shim(os: HostOs, executable_path: &Path) -> Option<InstallLayout> {
Expand Down Expand Up @@ -1031,10 +1078,11 @@ fn run_implicit_host_mode_if_needed() -> Result<Option<i32>> {
let executable_path = env::current_exe()?;

let args: Vec<OsString> = env::args_os().skip(1).collect();
if is_local_pwsh_apphost(&executable_path) {
if let Some(pwsh_dir) = resolve_local_pwsh_apphost_payload_dir(&executable_path) {
let os = HostOs::detect()?;
let venv_layout = default_current_user_layout(os)?;
let exit_code = run_known_host_executable(&executable_path, Some(&venv_layout), "local pwsh apphost", args)?;
let exit_code =
run_known_host_executable_for_pwsh_dir(&pwsh_dir, Some(&venv_layout), "local pwsh apphost", args)?;
return Ok(Some(exit_code));
}

Expand Down Expand Up @@ -3884,7 +3932,10 @@ mod tests {
fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();

assert!(is_local_pwsh_apphost(&executable_path));
assert_eq!(
resolve_local_pwsh_apphost_payload_dir(&executable_path).as_deref(),
Some(temp_dir.path())
);
}

#[test]
Expand All @@ -3895,18 +3946,44 @@ mod tests {
fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();

assert!(is_local_pwsh_apphost(&executable_path));
assert_eq!(
resolve_local_pwsh_apphost_payload_dir(&executable_path).as_deref(),
Some(temp_dir.path())
);
}

#[test]
fn is_local_pwsh_apphost_accepts_runtime_native_shared_payload() {
let temp_dir = TempDir::new().unwrap();
let native_dir = temp_dir.path().join("runtimes").join("win-x64").join("native");
fs::create_dir_all(&native_dir).unwrap();
let executable_path = native_dir.join("pwsh.exe");
fs::write(&executable_path, "").unwrap();
fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();

assert_eq!(
resolve_local_pwsh_apphost_payload_dir(&executable_path).as_deref(),
Some(temp_dir.path())
);
}

#[test]
fn is_local_pwsh_apphost_rejects_alias_name_with_adjacent_payload() {
fn is_local_pwsh_apphost_rejects_alias_name_with_local_payloads() {
let temp_dir = TempDir::new().unwrap();
let executable_path = temp_dir.path().join("pwsh-preview.exe");
fs::write(&executable_path, "").unwrap();
fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();

assert!(!is_local_pwsh_apphost(&executable_path));
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());

let native_dir = temp_dir.path().join("runtimes").join("win-x64").join("native");
fs::create_dir_all(&native_dir).unwrap();
let runtime_native_executable_path = native_dir.join("pwsh-preview.exe");
fs::write(&runtime_native_executable_path, "").unwrap();

assert!(resolve_local_pwsh_apphost_payload_dir(&runtime_native_executable_path).is_none());
}

#[test]
Expand All @@ -3915,14 +3992,32 @@ mod tests {
let executable_path = temp_dir.path().join("pwsh.exe");
fs::write(&executable_path, "").unwrap();

assert!(!is_local_pwsh_apphost(&executable_path));
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());

fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());

fs::remove_file(temp_dir.path().join("pwsh.dll")).unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());
}

#[test]
fn is_local_pwsh_apphost_rejects_runtime_native_missing_shared_payload() {
let temp_dir = TempDir::new().unwrap();
let native_dir = temp_dir.path().join("runtimes").join("linux-x64").join("native");
fs::create_dir_all(&native_dir).unwrap();
let executable_path = native_dir.join("pwsh");
fs::write(&executable_path, "").unwrap();

assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());

fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap();
assert!(!is_local_pwsh_apphost(&executable_path));
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());

fs::remove_file(temp_dir.path().join("pwsh.dll")).unwrap();
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();
assert!(!is_local_pwsh_apphost(&executable_path));
assert!(resolve_local_pwsh_apphost_payload_dir(&executable_path).is_none());
}

#[test]
Expand Down
11 changes: 4 additions & 7 deletions crates/multi-pwsh/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,10 @@ struct CommandParameterMetadata {
type_name: String,
}

pub fn run_stdio_mcp_server(executable: &Path, commands: &[String]) -> Result<i32, Box<dyn std::error::Error>> {
let pwsh_dir = executable.parent().ok_or_else(|| {
format!(
"failed to determine the PowerShell home directory from {}",
executable.display()
)
})?;
pub fn run_stdio_mcp_server_for_pwsh_dir(
pwsh_dir: &Path,
commands: &[String],
) -> Result<i32, Box<dyn std::error::Error>> {
let server = HostMcpServer::new(pwsh_dir, commands)?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
Expand Down
2 changes: 1 addition & 1 deletion crates/pwsh-host/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pwsh-host"
version = "0.14.0"
version = "0.14.1"
edition = "2018"
license = "MIT/Apache-2.0"
homepage = "https://github.com/Devolutions/pwsh-host-rs"
Expand Down
4 changes: 2 additions & 2 deletions docs/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This matrix reflects the current command surface and known gaps for `multi-pwsh`
| List | `multi-pwsh list [--scope <user\|machine\|all>] [--root <path>] [--available] [--include-prerelease]` | Installed listing shows paths, resolved aliases, named alias policies, and minor pins. Available listing queries GitHub releases; installed listings include prerelease versions automatically. |
| Offline cache | `multi-pwsh cache warm <selector> [--os <windows\|linux\|macos\|all>] [--arch <x64\|x86\|arm64\|arm32\|all>]` | Creates relocatable offline release bundles containing PowerShell archives, checksums, manifests, and optional `multi-pwsh` release artifacts. |
| Alias | `multi-pwsh alias set/unset` for `major.minor`, `pwsh`, `pwsh-preview`, and `pwsh-lts` | Minor aliases can be pinned or follow latest in line. Named aliases store policies and resolve only to installed versions. |
| Host | `multi-pwsh host <version\|major\|major.minor\|pwsh-alias> [pwsh arguments...]` | Runs through the native host. Alias shims can invoke host mode implicitly from the managed bin directory; a renamed local `pwsh`/`pwsh.exe` can also host an adjacent `pwsh.dll` plus `pwsh.runtimeconfig.json` SDK payload. |
| Host | `multi-pwsh host <version\|major\|major.minor\|pwsh-alias> [pwsh arguments...]` | Runs through the native host. Alias shims can invoke host mode implicitly from the managed bin directory; a renamed local `pwsh`/`pwsh.exe` can also host adjacent or `runtimes/<rid>/native/` SDK payload layouts. |
| MCP host bridge | `multi-pwsh host <selector> -mcp -McpCommands <command> [command ...]` | Starts a stdio MCP server over a hosted PowerShell runspace and exposes selected commands as tools. Extra `pwsh` arguments are rejected in MCP mode; `-venv` is supported. |
| Virtual environments | `multi-pwsh venv create/delete/export/import/list` plus host `-VirtualEnvironment` / `-venv` | Provides a managed module root for hosted PowerShell launches. |
| Doctor | `multi-pwsh doctor --repair-aliases` | Repairs host shims, alias files, and managed named alias policy resolutions. |
Expand Down Expand Up @@ -77,7 +77,7 @@ Install, update, uninstall, and `doctor --repair-aliases` all reconcile aliases.
| --- | --- | --- |
| Native host launch | Yes | `multi-pwsh host` resolves selectors to installed executables and runs through `pwsh-host`. |
| Implicit shim host mode | Yes | Alias shims detect their own name and layout, then run the matching selector. |
| Local `pwsh` apphost replacement | Yes | Exact `pwsh`/`pwsh.exe` beside `pwsh.dll` and `pwsh.runtimeconfig.json` bypasses alias policy and hosts that adjacent payload directly. |
| Local `pwsh` apphost replacement | Yes | Exact `pwsh`/`pwsh.exe` bypasses alias policy and hosts either adjacent `pwsh.dll`/`pwsh.runtimeconfig.json` markers or the shared payload three directories above `runtimes/<rid>/native/`. |
| Reusable AppHost NuGet mode | Yes | `Devolutions.MultiPwsh.Cli` packages RID-specific binaries and opt-in `buildTransitive` targets for downstream apphost replacement. |
| MCP stdio server | Yes | Exposes explicitly selected PowerShell commands as MCP tools using the selected hosted version; tool names normalize to `powershell_*`. |
| Virtual environment module path | Yes | Host mode sets startup-hook environment variables and bootstraps module cmdlet aliases for `-Command` and stdin `-File -` scenarios. |
Expand Down
6 changes: 3 additions & 3 deletions docs/host-and-venv.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@

As an advanced replacement workflow, `multi-pwsh` can be renamed to `pwsh`/`pwsh.exe` and placed directly in a PowerShell SDK/apphost output directory. This mode is intentionally separate from managed alias-shim mode.

Detection uses the executable path reported by the OS, not the current working directory and not `PATH`. It activates only when the executable name is exactly `pwsh` or `pwsh.exe` and the same directory contains both `pwsh.dll` and `pwsh.runtimeconfig.json`. Additional files such as `System.Management.Automation.dll`, `Microsoft.PowerShell.ConsoleHost.dll`, and `Modules/` are expected in complete PowerShell payloads but are not required as marker files.
Detection uses the executable path reported by the OS, not the current working directory and not `PATH`. It activates only when the executable name is exactly `pwsh` or `pwsh.exe` and either the same directory contains both `pwsh.dll` and `pwsh.runtimeconfig.json`, or the executable is under `runtimes/<rid>/native/` and those marker files exist three directories up at the shared publish root. Additional files such as `System.Management.Automation.dll`, `Microsoft.PowerShell.ConsoleHost.dll`, and `Modules/` are expected in complete PowerShell payloads but are not required as marker files.

When this local payload probe succeeds, `multi-pwsh` bypasses the managed `pwsh` alias policy and layout-shim inference, then hosts the adjacent `pwsh.dll` directly. Host-side preprocessing still applies, including `-venv` / `-VirtualEnvironment`, `-NamedPipeCommand`, stdin command rewriting, MCP mode, startup-hook setup, and PowerShell update-check suppression.
When this local payload probe succeeds, `multi-pwsh` bypasses the managed `pwsh` alias policy and layout-shim inference, then hosts `pwsh.dll` from the resolved payload directory. Host-side preprocessing still applies, including `-venv` / `-VirtualEnvironment`, `-NamedPipeCommand`, stdin command rewriting, MCP mode, startup-hook setup, and PowerShell update-check suppression.

Hostfxr loading is app-local first. If `hostfxr` is not present beside the payload, `pwsh-host` falls back to the .NET hosting layer via `nethost`/global .NET roots, which supports framework-dependent SDK build output. Self-contained payloads still need their app-local hosting files such as `hostfxr` and `hostpolicy`.

Expand All @@ -33,7 +33,7 @@ Typical downstream vendored-SDK usage:

```xml
<ItemGroup>
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.0" PrivateAssets="all" />
<PackageReference Include="Devolutions.MultiPwsh.Cli" Version="0.14.1" PrivateAssets="all" />
</ItemGroup>

<PropertyGroup>
Expand Down
Loading