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
56 changes: 52 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ jobs:
run: dotnet test dotnet/bindings/Devolutions.PowerShell.SDK.Bindings.csproj --no-build -p:PwshExePath="$env:PwshExePath"

nuget-cli:
name: NuGet CLI package smoke (Windows)
name: NuGet package smoke (Windows)
runs-on: windows-latest
needs: test

Expand All @@ -194,17 +194,17 @@ jobs:
rustup toolchain install stable --profile minimal --target x86_64-pc-windows-msvc
rustup default stable

- name: Build and pack CLI NuGet package
- name: Build and pack native NuGet packages
shell: pwsh
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackage.ps1') `
pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-NativeNuGetPackages.ps1') `
-RuntimeIdentifiers 'win-x64' `
-Clean

- name: Smoke test PackageReference copy targets
shell: pwsh
run: |
$packageSource = (Resolve-Path 'artifacts\cli-nuget').Path
$packageSource = (Resolve-Path 'artifacts\native-nuget').Path
$smokeRoot = Join-Path $env:RUNNER_TEMP 'multi-pwsh-cli-package-smoke'
$env:NUGET_PACKAGES = Join-Path $env:RUNNER_TEMP 'multi-pwsh-cli-package-smoke-nuget-cache'
New-Item -Path $smokeRoot -ItemType Directory -Force | Out-Null
Expand Down Expand Up @@ -249,3 +249,51 @@ jobs:
if (-not (Test-Path -Path $expected -PathType Leaf)) {
throw "Expected smoke output file was not found: $expected"
}

- name: Smoke test AppHost package targets
shell: pwsh
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'tests\Invoke-AppHostNuGetPackageSmokeTest.ps1') `
-PackageSource (Resolve-Path 'artifacts\native-nuget') `
-RuntimeIdentifier 'win-x64'

nuget-apphost-linux:
name: NuGet AppHost smoke (Linux)
runs-on: ubuntu-latest
needs: test

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

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

- name: Install PowerShell 7.4 for binding discovery
shell: pwsh
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/Install-PowerShell74ForCi.ps1

- name: Install Rust toolchain
shell: pwsh
run: |
rustup toolchain install stable --profile minimal --target x86_64-unknown-linux-gnu
rustup default stable

- name: Build and pack AppHost NuGet package
shell: pwsh
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts/Build-NativeNuGetPackages.ps1') `
-RuntimeIdentifiers 'linux-x64' `
-Packages 'AppHost' `
-Clean

- name: Smoke test AppHost package targets
shell: pwsh
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'tests/Invoke-AppHostNuGetPackageSmokeTest.ps1') `
-PackageSource (Resolve-Path 'artifacts/native-nuget') `
-RuntimeIdentifier 'linux-x64'
22 changes: 15 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -546,21 +546,21 @@ jobs:
Copy-Item -Path $binaryPath -Destination (Join-Path $targetDir $mapping.Binary) -Force
}

- name: Pack CLI NuGet package
- name: Pack native NuGet packages
shell: pwsh
env:
PACKAGE_VERSION: ${{ needs.validate.outputs.release_version }}
run: |
pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-CliNativeNuGetPackage.ps1') `
pwsh -NoLogo -NoProfile -File (Resolve-Path 'scripts\Build-NativeNuGetPackages.ps1') `
-Version $env:PACKAGE_VERSION `
-NoBuild `
-Clean:$false

- name: Upload CLI NuGet package artifact
- name: Upload native NuGet package artifacts
uses: actions/upload-artifact@v4
with:
name: cli-nuget
path: artifacts/cli-nuget/*.nupkg
name: native-nuget
path: artifacts/native-nuget/*.nupkg
if-no-files-found: error

- name: Generate checksums
Expand Down Expand Up @@ -598,18 +598,26 @@ jobs:
Where-Object { $_.Name -like 'multi-pwsh-*.zip' } |
Sort-Object Name |
ForEach-Object { $_.FullName }
$nugetPackages = Get-ChildItem -Path artifacts/cli-nuget -Filter 'Devolutions.MultiPwsh.Cli.*.nupkg' |
$cliNugetPackages = Get-ChildItem -Path artifacts/native-nuget -Filter 'Devolutions.MultiPwsh.Cli.*.nupkg' |
Sort-Object Name |
ForEach-Object { $_.FullName }
$appHostNugetPackages = Get-ChildItem -Path artifacts/native-nuget -Filter 'Devolutions.MultiPwsh.AppHost.*.nupkg' |
Sort-Object Name |
ForEach-Object { $_.FullName }
$nugetPackages = @($cliNugetPackages) + @($appHostNugetPackages)

if (-not $zipFiles) {
throw 'No release archives found under dist'
}

if (-not $nugetPackages) {
if (-not $cliNugetPackages) {
throw 'No CLI NuGet package was produced.'
}

if (-not $appHostNugetPackages) {
throw 'No AppHost NuGet package was produced.'
}

gh release view "$env:RELEASE_TAG" *> $null

if ($LASTEXITCODE -eq 0) {
Expand Down
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.

48 changes: 40 additions & 8 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.11.0`):
Install a specific tag (example `v0.13.0`):

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

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

Uninstall bootstrap scripts:
Expand Down Expand Up @@ -58,7 +58,7 @@ pwsh-7.4 --version

GitHub remains the default source, but you can prefetch release artifacts on a connected machine and use them from a disconnected machine.

On a connected machine, warm an offline bundle into the same directory shape used by the download cache:
On a connected machine, warm an offline release bundle:

```powershell
multi-pwsh cache warm stable --os windows --arch x64 --output \\fileserver\multi-pwsh-cache
Expand All @@ -74,7 +74,9 @@ multi-pwsh install stable
multi-pwsh update 7.4
```

You can also pass `--offline-cache <path>` to `install`, `update`, `package install`, and `list --available`. Offline mode uses only the local manifest/artifacts and fails instead of falling back to GitHub when something is missing.
You can also pass `--offline-cache <path>` to `install`, `update`, and `list --available`. Offline mode uses only the local manifest/artifacts and fails instead of falling back to GitHub when something is missing.

`MULTI_PWSH_OFFLINE_CACHE` selects a warmed offline release bundle. `MULTI_PWSH_CACHE_DIR` is separate: it is the user-scope archive/download cache and the default output location for `cache warm` when `--output` is omitted. Empty or whitespace-only path environment variable values are treated as unset.

Disconnected bootstrap uses the same bundle:

Expand Down Expand Up @@ -105,6 +107,10 @@ That means:
- 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
- machine installs and removals require explicit `--scope machine`
- `MULTI_PWSH_*` path environment variables affect only the default `user` layout
- `machine` scope uses fixed platform machine paths and does not read `MULTI_PWSH_*` path overrides
- `--root` is an explicit install-root override, requires `--scope <user|machine>`, and does not mix in `MULTI_PWSH_*` child-directory overrides

Platform behavior:

Expand All @@ -113,7 +119,10 @@ Platform behavior:
- Windows `machine` installs default to `%ProgramFiles%\PowerShell` with aliases in `%ProgramFiles%\PowerShell\bin`.
- 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`.
- Windows `machine` installs may require elevation for `%ProgramFiles%`, registry integrations, and Machine `PATH` updates; use `--no-add-path` if you want to skip the PATH update.
- Unix `machine` installs expect you to provide elevation yourself; `multi-pwsh` does not invoke `sudo`.
- On Windows, `--add-path` / `--no-add-path` controls persistent User or Machine `PATH` updates. On macOS/Linux, `--add-path` is unsupported, `--no-add-path` is accepted as a no-op, and shell/profile PATH updates are manual.
- New scoped installs are metadata-backed. Older non-Windows filesystem-only installs that predate scoped metadata may need to be reinstalled or migrated before scoped `list` / `uninstall` can manage them.

Examples:

Expand Down Expand Up @@ -147,7 +156,8 @@ On macOS and Linux, scoped installs support:
- `--root <path>`
- `--arch <auto|x64|x86|arm64|arm32>`
- `--include-prerelease`
- `--add-path` / `--no-add-path`
- `--no-add-path` as a cross-platform no-op
- shell/profile `PATH` updates are manual on Unix; `--add-path` is Windows-only

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

Expand Down Expand Up @@ -229,7 +239,9 @@ multi-pwsh doctor --repair-aliases

Use `multi-pwsh <command> --help` or `multi-pwsh help <command>` for focused command usage, for example `multi-pwsh install --help`.

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.
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`, `--no-add-path`, and manual PATH management for the printed alias bin directory. Whenever `--root` is used with install, update, list, or uninstall, pass `--scope <user|machine>` as well.

`multi-pwsh package ...` remains available as an advanced compatibility command for the scoped install backend, but the top-level commands above are the primary interface.

## Selector behavior

Expand Down Expand Up @@ -259,11 +271,31 @@ The current LTS line is encoded in the tool; at the moment that is `7.6`.
- Use `-venv <name>` or `-VirtualEnvironment <name>` to select a managed module root for hosted launches.
- Use `multi-pwsh doctor --repair-aliases` to repair host shims and named aliases.

See [docs/host-and-venv.md](docs/host-and-venv.md) for host shims, venv layout, import/export, managed paths, and current limitations.
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`.

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.

## Reusable AppHost NuGet package

`Devolutions.MultiPwsh.AppHost` packages RID-specific `multi-pwsh` binaries and opt-in MSBuild targets for downstream packages that need to copy `multi-pwsh` as a replacement apphost. The package is inert by default.

```xml
<ItemGroup>
<PackageReference Include="Devolutions.MultiPwsh.AppHost" Version="0.13.0" PrivateAssets="all" />
</ItemGroup>

<PropertyGroup>
<MultiPwshAppHostEnabled>true</MultiPwshAppHostEnabled>
<MultiPwshAppHostOutputBaseName>pwsh</MultiPwshAppHostOutputBaseName>
</PropertyGroup>
```

When enabled, the package resolves the RID from `MultiPwshAppHostRuntimeIdentifier`, `PowerShellSDKAppHostRuntimeIdentifier`, `RuntimeIdentifier`, then `NETCoreSdkRuntimeIdentifier`, copies the selected binary to build and publish output, and appends `.exe` for Windows RIDs. Set `MultiPwshAppHostOutputName` for a full explicit file name, or set `MultiPwshAppHostCopyToOutput` / `MultiPwshAppHostCopyToPublish` to `false` and consume `MultiPwshAppHostResolvedNativeBinary` / `@(MultiPwshAppHostNativeBinary)` from custom targets.

## Testing

- Scoped install smoke tests: `pwsh -NoLogo -NoProfile -NonInteractive -File .\tests\Invoke-ScopedInstallSmokeTest.ps1`
- Venv matrix tests: `pwsh -NoLogo -NoProfile -NonInteractive -File .\tests\Invoke-VenvTestMatrix.ps1`
- AppHost NuGet package smoke test: `pwsh -NoLogo -NoProfile -NonInteractive -File .\tests\Invoke-AppHostNuGetPackageSmokeTest.ps1`

See [docs/testing.md](docs/testing.md) for online test mode, alias-targeted runs, and troubleshooting flags.
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.12.1"
version = "0.13.0"
edition = "2018"
license = "MIT/Apache-2.0"
homepage = "https://github.com/Devolutions/multi-pwsh"
Expand Down
79 changes: 67 additions & 12 deletions crates/multi-pwsh/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,10 @@ pub struct InstallLayout {
impl InstallLayout {
pub fn new(os: HostOs) -> Result<Self> {
let user_home = home::home_dir().ok_or(MultiPwshError::HomeDirectoryNotFound)?;
let home = env::var_os("MULTI_PWSH_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| user_home.join(".pwsh"));
let bin_dir = env::var_os("MULTI_PWSH_BIN_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| join_layout_path(&home, "bin"));
let cache_dir = env::var_os("MULTI_PWSH_CACHE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| join_layout_path(&home, "cache"));
let venvs_dir = env::var_os("MULTI_PWSH_VENV_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| join_layout_path(&home, "venv"));
let home = path_env_var("MULTI_PWSH_HOME").unwrap_or_else(|| user_home.join(".pwsh"));
let bin_dir = path_env_var("MULTI_PWSH_BIN_DIR").unwrap_or_else(|| join_layout_path(&home, "bin"));
let cache_dir = path_env_var("MULTI_PWSH_CACHE_DIR").unwrap_or_else(|| join_layout_path(&home, "cache"));
let venvs_dir = path_env_var("MULTI_PWSH_VENV_DIR").unwrap_or_else(|| join_layout_path(&home, "venv"));
let versions_dir = join_layout_path(&home, "multi");

Ok(InstallLayout {
Expand Down Expand Up @@ -183,6 +175,15 @@ impl InstallLayout {
}
}

pub(crate) fn path_env_var(name: &str) -> Option<PathBuf> {
let value = env::var_os(name)?;
if value.to_string_lossy().trim().is_empty() {
return None;
}

Some(PathBuf::from(value))
}

fn join_layout_path(base: &Path, child: &str) -> PathBuf {
let base_text = base.to_string_lossy();
if !looks_like_windows_path(base_text.as_ref()) {
Expand Down Expand Up @@ -263,6 +264,24 @@ mod tests {
result
}

fn with_env_var_text<T>(key: &str, value: Option<&str>, action: impl FnOnce() -> T) -> T {
let previous = env::var_os(key);

match value {
Some(value) => unsafe { env::set_var(key, value) },
None => unsafe { env::remove_var(key) },
}

let result = action();

match previous {
Some(value) => unsafe { env::set_var(key, value) },
None => unsafe { env::remove_var(key) },
}

result
}

fn with_layout_env<T>(
home: Option<&Path>,
bin_dir: Option<&Path>,
Expand Down Expand Up @@ -336,6 +355,42 @@ mod tests {
});
}

#[test]
fn path_env_var_ignores_empty_and_whitespace_values() {
let _guard = crate::TEST_ENV_LOCK.lock().unwrap();

with_env_var_text("MULTI_PWSH_HOME", Some(""), || {
assert_eq!(path_env_var("MULTI_PWSH_HOME"), None);
});

with_env_var_text("MULTI_PWSH_HOME", Some(" \t "), || {
assert_eq!(path_env_var("MULTI_PWSH_HOME"), None);
});
}

#[test]
fn layout_ignores_empty_child_overrides() {
let temp_dir = TempDir::new().unwrap();
let expected_home = temp_dir.path().join("pwsh-home");

let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
with_env_var("MULTI_PWSH_HOME", Some(&expected_home), || {
with_env_var_text("MULTI_PWSH_BIN_DIR", Some(""), || {
with_env_var_text("MULTI_PWSH_CACHE_DIR", Some(" "), || {
with_env_var_text("MULTI_PWSH_VENV_DIR", Some("\t"), || {
let layout = InstallLayout::new(HostOs::Linux).unwrap();

assert_eq!(layout.home(), expected_home.as_path());
assert_eq!(layout.bin_dir(), expected_home.join("bin"));
assert_eq!(layout.cache_dir(), expected_home.join("cache"));
assert_eq!(layout.venvs_dir(), expected_home.join("venv"));
assert_eq!(layout.versions_dir(), expected_home.join("multi"));
})
})
})
});
}

#[test]
fn version_dir_falls_back_to_legacy_location() {
let temp_dir = TempDir::new().unwrap();
Expand Down
Loading