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
77 changes: 77 additions & 0 deletions .github/workflows/smoke-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Smoke tests

on:
push:
branches:
- "**"
tags-ignore:
- "v*"
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
scoped-install-smoke:
name: Smoke tests (${{ matrix.name }})
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- name: windows
os: windows-latest
- name: macos
os: macos-14
- name: linux
os: ubuntu-latest

env:
GITHUB_TOKEN: ${{ github.token }}
MULTI_PWSH_CACHE_KEEP: "1"

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

- name: Install Rust toolchain
shell: pwsh
run: |
rustup toolchain install stable --profile minimal
rustup default stable

- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2.8.2

- name: Cache multi-pwsh downloads
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/multi-pwsh-cache
key: multi-pwsh-scope-smoke-${{ runner.os }}-${{ hashFiles('crates/multi-pwsh/Cargo.toml', '.github/workflows/smoke-tests.yml', 'tests/Invoke-ScopedInstallSmokeTest.ps1') }}
restore-keys: |
multi-pwsh-scope-smoke-${{ runner.os }}-

- name: Install multi-pwsh from source
shell: pwsh
run: |
cargo install --locked --path crates/multi-pwsh --force

- name: Add cargo bin to PATH
shell: pwsh
run: |
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $HOME '.cargo' }
$cargoBin = Join-Path $cargoHome 'bin'
$cargoBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

- name: Run smoke tests
shell: pwsh
env:
MULTI_PWSH_CACHE_DIR: ${{ runner.temp }}/multi-pwsh-cache
run: ./tests/Invoke-ScopedInstallSmokeTest.ps1
63 changes: 58 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,57 @@ pwsh-7.4 --version
pwsh-7.5 --version
```

## Scoped installs

`multi-pwsh install`, `update`, `uninstall`, and `list` now support `--scope <user|machine>` across Windows, macOS, and Linux.

That means:

- extracted versions stay side-by-side under the selected install root
- aliases continue to live in one stable bin directory
- PATH only needs one entry per scope
- `user` is the default scope when `--scope` is omitted

Platform behavior:

- Windows uses the GitHub ZIP archives with MSI-like install roots and selected installer-style integrations that still make sense for archive installs.
- macOS `machine` installs use the official `.tar.gz` archives under `/usr/local/microsoft/powershell` with aliases published to `/usr/local/bin`.
- Linux `machine` installs use the official `.tar.gz` archives under `/opt/microsoft/powershell` with aliases published to `/usr/local/bin`.
- Unix `machine` installs expect you to provide elevation yourself; `multi-pwsh` does not invoke `sudo`.

Examples:

```powershell
multi-pwsh install 7.4
multi-pwsh install 7.5 --scope machine --enable-psremoting --add-explorer-context-menu
multi-pwsh install 7.5 --scope machine
multi-pwsh list --scope all
multi-pwsh uninstall 7.4.13 --scope machine
```

Windows scoped-install flags mirror the most useful MSI-style options:

- `--add-path` / `--no-add-path`
- `--register-manifest` / `--no-register-manifest`
- `--enable-psremoting`
- `--disable-telemetry`
- `--add-explorer-context-menu`
- `--add-file-context-menu`
- `--scope <user|machine>`
- `--root <path>`

Microsoft Update registration is intentionally out of scope for archive installs at the moment, even on Windows.

On macOS and Linux, scoped installs support:

- `--scope <user|machine>`
- `--root <path>`
- `--arch <auto|x64|x86|arm64|arm32>`
- `--include-prerelease`
- `--add-path` / `--no-add-path`

The Windows-only integration flags above currently return an error on macOS/Linux.

## Manage installed lines

```powershell
Expand Down Expand Up @@ -79,10 +130,10 @@ multi-pwsh doctor --repair-aliases
`multi-pwsh` usage reference:

```text
multi-pwsh install <version|major|major.minor|major.minor.x> [--arch <auto|x64|x86|arm64|arm32>] [--include-prerelease]
multi-pwsh update <major.minor> [--arch <auto|x64|x86|arm64|arm32>] [--include-prerelease]
multi-pwsh uninstall <version> [--force]
multi-pwsh list [--available] [--include-prerelease]
multi-pwsh install <version|major|major.minor|major.minor.x> [--scope <user|machine>] [--root <path>] [--arch <auto|x64|x86|arm64|arm32>] [--include-prerelease] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]
multi-pwsh update <major.minor> [--scope <user|machine>] [--root <path>] [--arch <auto|x64|x86|arm64|arm32>] [--include-prerelease] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]
multi-pwsh uninstall <version> [--scope <user|machine>] [--root <path>] [--force]
multi-pwsh list [--scope <user|machine|all>] [--root <path>] [--available] [--include-prerelease]
multi-pwsh venv create <name>
multi-pwsh venv delete <name>
multi-pwsh venv export <name> <archive.zip>
Expand All @@ -94,6 +145,8 @@ multi-pwsh host <version|major|major.minor|pwsh-alias> [-VirtualEnvironment <nam
multi-pwsh doctor --repair-aliases
```

The Windows integration flags in the `install` and `update` forms are limited to archive-friendly behaviors; on macOS/Linux, use `--scope`, `--root`, `--arch`, `--include-prerelease`, and `--add-path` controls. Legacy scope aliases such as `current-user` and `all-users` are still accepted for compatibility.

### Venv cmdlet matrix tests (Pester)

Use the local Pester harness to validate venv-sensitive cmdlet behavior across installed version aliases (`pwsh-x.y.z`).
Expand Down Expand Up @@ -148,7 +201,7 @@ Native host mode:
- `-VirtualEnvironment <name>` and `-venv <name>` are consumed by `multi-pwsh` before handing control to PowerShell and set `PSModulePath` to the selected venv root for that launch.
- `PSMODULE_VENV_PATH` can also be used as an explicit path-based venv selector for hosted launches. If it is already set in the environment, `multi-pwsh host` treats it as an intentional venv opt-in.
- Alias lifecycle now maintains native host shims as hard links to `multi-pwsh` automatically during install/update/doctor alias repair.
- On Windows, host shims are `pwsh-*.exe` files alongside `.cmd` wrappers in `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`).
- On Windows, alias command paths are `pwsh-*.exe` host shims in `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`).
- On Linux/macOS, alias command paths (`pwsh-*`) are hard links to `multi-pwsh`.
- `multi-pwsh doctor --repair-aliases` performs a shim health check and re-links broken hard links automatically.
- You can still manually copy/rename `multi-pwsh.exe` under `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`) to an alias-like name (for example `pwsh-7.4.exe`); it automatically enters host mode and resolves the target installation from that alias name.
Expand Down
117 changes: 82 additions & 35 deletions crates/multi-pwsh/src/aliases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use crate::layout::InstallLayout;
use crate::platform::HostOs;
use crate::versions::MajorMinor;

const LAYOUT_HINT_FILE: &str = "multi-pwsh-layout.json";

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AliasSelector {
Major(u64),
Expand Down Expand Up @@ -57,17 +59,15 @@ fn create_or_update_alias_with_selector(
target: &Path,
) -> Result<PathBuf> {
fs::create_dir_all(layout.bin_dir())?;
write_layout_hint(layout)?;
let _ = target;

let alias_command = alias_command_name(&selector);
let alias_file = alias_file_name(&selector, os);
let alias_path = layout.bin_dir().join(alias_file);

match os {
HostOs::Windows => {
if alias_path.exists() {
fs::remove_file(&alias_path)?;
}
create_windows_cmd_alias(target, &alias_path)?;
create_or_update_windows_host_shim(layout, &alias_command)?;
}
HostOs::Linux | HostOs::Macos => {
Expand All @@ -82,6 +82,24 @@ fn create_or_update_alias_with_selector(
Ok(alias_path)
}

pub fn read_layout_hint(bin_dir: &Path, os: HostOs) -> Result<Option<InstallLayout>> {
let path = layout_hint_path(bin_dir);
if !path.exists() {
return Ok(None);
}

let content = fs::read_to_string(path)?;
let hint: LayoutHint = serde_json::from_str(&content)?;
Ok(Some(InstallLayout::from_parts(
os,
PathBuf::from(hint.home),
PathBuf::from(hint.bin_dir),
PathBuf::from(hint.cache_dir),
PathBuf::from(hint.venvs_dir),
PathBuf::from(hint.versions_dir),
)?))
}

pub fn remove_alias(layout: &InstallLayout, os: HostOs, alias_command: &str) -> Result<bool> {
let alias_path = layout.bin_dir().join(alias_file_name_from_command(alias_command, os));
let mut removed = false;
Expand All @@ -92,9 +110,9 @@ pub fn remove_alias(layout: &InstallLayout, os: HostOs, alias_command: &str) ->
}

if os == HostOs::Windows {
let host_shim_path = layout.bin_dir().join(format!("{}.exe", alias_command));
if host_shim_path.exists() {
fs::remove_file(host_shim_path)?;
let legacy_cmd_path = layout.bin_dir().join(format!("{}.cmd", alias_command));
if legacy_cmd_path.exists() {
fs::remove_file(legacy_cmd_path)?;
removed = true;
}
}
Expand Down Expand Up @@ -224,7 +242,7 @@ fn alias_file_name(selector: &AliasSelector, os: HostOs) -> String {

fn alias_file_name_from_command(alias_command: &str, os: HostOs) -> String {
match os {
HostOs::Windows => format!("{}.cmd", alias_command),
HostOs::Windows => format!("{}.exe", alias_command),
HostOs::Linux | HostOs::Macos => alias_command.to_string(),
}
}
Expand All @@ -237,26 +255,6 @@ fn alias_command_name(selector: &AliasSelector) -> String {
}
}

#[cfg(windows)]
fn create_windows_cmd_alias(target: &Path, alias_path: &Path) -> Result<()> {
let target_string = target
.to_str()
.ok_or_else(|| MultiPwshError::AliasCreation("target path is not valid UTF-8".to_string()))?;

let script = format!("@echo off\r\n\"{}\" %*\r\nexit /b %ERRORLEVEL%\r\n", target_string);

fs::write(alias_path, script).map_err(|error| {
MultiPwshError::AliasCreation(format!(
"failed to write windows command alias '{}' -> '{}': {}",
alias_path.display(),
target.display(),
error
))
})?;

Ok(())
}

#[cfg(windows)]
fn create_or_update_windows_host_shim(layout: &InstallLayout, alias_command: &str) -> Result<bool> {
let alias_exe_path = layout.bin_dir().join(format!("{}.exe", alias_command));
Expand Down Expand Up @@ -390,11 +388,21 @@ fn resolve_host_shim_source(layout: &InstallLayout) -> Result<PathBuf> {
))
}

#[cfg(not(windows))]
fn create_windows_cmd_alias(_target: &Path, _alias_path: &Path) -> Result<()> {
Err(MultiPwshError::AliasCreation(
"windows command alias is not available on this platform".to_string(),
))
fn write_layout_hint(layout: &InstallLayout) -> Result<()> {
let hint = LayoutHint {
home: layout.home().display().to_string(),
bin_dir: layout.bin_dir().display().to_string(),
cache_dir: layout.cache_dir().display().to_string(),
venvs_dir: layout.venvs_dir().display().to_string(),
versions_dir: layout.versions_dir().display().to_string(),
};
let payload = serde_json::to_string_pretty(&hint)?;
fs::write(layout_hint_path(&layout.bin_dir()), payload)?;
Ok(())
}

fn layout_hint_path(bin_dir: &Path) -> PathBuf {
bin_dir.join(LAYOUT_HINT_FILE)
}

#[derive(Debug, Default, Serialize, Deserialize)]
Expand All @@ -405,9 +413,19 @@ struct AliasMetadata {
pins: HashMap<String, String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct LayoutHint {
home: String,
bin_dir: String,
cache_dir: String,
venvs_dir: String,
versions_dir: String,
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn alias_name_uses_major_minor() {
Expand All @@ -419,15 +437,15 @@ mod tests {
);
assert_eq!(
alias_file_name(&AliasSelector::MajorMinor(line), HostOs::Windows),
"pwsh-7.4.cmd"
"pwsh-7.4.exe"
);
}

#[test]
fn alias_name_supports_major() {
assert_eq!(alias_command_name(&AliasSelector::Major(7)), "pwsh-7");
assert_eq!(alias_file_name(&AliasSelector::Major(7), HostOs::Linux), "pwsh-7");
assert_eq!(alias_file_name(&AliasSelector::Major(7), HostOs::Windows), "pwsh-7.cmd");
assert_eq!(alias_file_name(&AliasSelector::Major(7), HostOs::Windows), "pwsh-7.exe");
}

#[test]
Expand All @@ -453,4 +471,33 @@ mod tests {
assert!(parse_alias_command_selector("pwsh").is_none());
assert!(parse_alias_command_selector("not-pwsh-7.5").is_none());
}

#[test]
fn layout_hint_round_trips_shared_bin_layout() {
let temp_dir = TempDir::new().unwrap();
let home = temp_dir.path().join("payload-root");
let bin_dir = temp_dir.path().join("shared-bin");
let cache_dir = temp_dir.path().join("cache-root");
let venvs_dir = temp_dir.path().join("venv-root");
let versions_dir = temp_dir.path().join("versions-root");
fs::create_dir_all(&bin_dir).unwrap();
let layout = InstallLayout::from_parts(
HostOs::Linux,
home.clone(),
bin_dir.clone(),
cache_dir.clone(),
venvs_dir.clone(),
versions_dir.clone(),
)
.unwrap();

write_layout_hint(&layout).unwrap();
let loaded = read_layout_hint(&bin_dir, HostOs::Linux).unwrap().unwrap();

assert_eq!(loaded.home(), home.as_path());
assert_eq!(loaded.bin_dir(), bin_dir);
assert_eq!(loaded.cache_dir(), cache_dir);
assert_eq!(loaded.venvs_dir(), venvs_dir);
assert_eq!(loaded.versions_dir(), versions_dir);
}
}
Loading
Loading