diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4d2675..153322e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd67cc8..5d3be25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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) { diff --git a/Cargo.lock b/Cargo.lock index 0c12ec9..f102afe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,7 +780,7 @@ dependencies = [ [[package]] name = "multi-pwsh" -version = "0.12.1" +version = "0.13.0" dependencies = [ "flate2", "home", @@ -929,7 +929,7 @@ dependencies = [ [[package]] name = "pwsh-host" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.13.1", "cfg-if 0.1.10", diff --git a/README.md b/README.md index 9a90cb4..e91f66d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -74,7 +74,9 @@ multi-pwsh install stable multi-pwsh update 7.4 ``` -You can also pass `--offline-cache ` 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 ` 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: @@ -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 `, and does not mix in `MULTI_PWSH_*` child-directory overrides Platform behavior: @@ -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: @@ -147,7 +156,8 @@ On macOS and Linux, scoped installs support: - `--root ` - `--arch ` - `--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. @@ -229,7 +239,9 @@ multi-pwsh doctor --repair-aliases Use `multi-pwsh --help` or `multi-pwsh help ` 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 ` 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 @@ -259,11 +271,31 @@ The current LTS line is encoded in the tool; at the moment that is `7.6`. - Use `-venv ` or `-VirtualEnvironment ` 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 + + + + + + true + pwsh + +``` + +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. diff --git a/crates/multi-pwsh/Cargo.toml b/crates/multi-pwsh/Cargo.toml index 6531fa5..267a8b2 100644 --- a/crates/multi-pwsh/Cargo.toml +++ b/crates/multi-pwsh/Cargo.toml @@ -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" diff --git a/crates/multi-pwsh/src/layout.rs b/crates/multi-pwsh/src/layout.rs index 749d598..a07cbc6 100644 --- a/crates/multi-pwsh/src/layout.rs +++ b/crates/multi-pwsh/src/layout.rs @@ -19,18 +19,10 @@ pub struct InstallLayout { impl InstallLayout { pub fn new(os: HostOs) -> Result { 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 { @@ -183,6 +175,15 @@ impl InstallLayout { } } +pub(crate) fn path_env_var(name: &str) -> Option { + 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()) { @@ -263,6 +264,24 @@ mod tests { result } + fn with_env_var_text(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( home: Option<&Path>, bin_dir: Option<&Path>, @@ -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(); diff --git a/crates/multi-pwsh/src/main.rs b/crates/multi-pwsh/src/main.rs index 502756e..2c39f86 100644 --- a/crates/multi-pwsh/src/main.rs +++ b/crates/multi-pwsh/src/main.rs @@ -28,7 +28,7 @@ use aliases::{ }; use error::{MultiPwshError, Result}; use install::{copy_asset_to_path, ensure_installed, validate_archive_checksum, ChecksumSource}; -use layout::InstallLayout; +use layout::{path_env_var, InstallLayout}; use package::{ load_package_metadata, package_layout, persist_installed_version_registration, persist_installer_properties, reconcile_shared_integrations, remove_installed_version_registration, run_install_time_actions, @@ -47,7 +47,6 @@ use versions::{ const POWERSHELL_UPDATECHECK_ENV_VAR: &str = "POWERSHELL_UPDATECHECK"; const POWERSHELL_UPDATECHECK_OFF: &str = "Off"; const MULTI_PWSH_OFFLINE_CACHE_ENV_VAR: &str = "MULTI_PWSH_OFFLINE_CACHE"; -const MULTI_PWSH_RELEASE_SOURCE_ENV_VAR: &str = "MULTI_PWSH_RELEASE_SOURCE"; const VIRTUAL_ENVIRONMENT_FLAG: &str = "-virtualenvironment"; const VIRTUAL_ENVIRONMENT_SHORT_FLAG: &str = "-venv"; @@ -63,12 +62,11 @@ const HELP_TOPICS: &[&str] = &[ "host", "doctor", "cache", - "package", "version", ]; fn usage_text() -> &'static str { - "Usage:\n multi-pwsh --version\n multi-pwsh -V\n multi-pwsh --help\n multi-pwsh help [command]\n multi-pwsh install [--scope ] [--root ] [--arch ] [--include-prerelease] [--offline-cache ] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]\n multi-pwsh update [--scope ] [--root ] [--arch ] [--include-prerelease] [--offline-cache ] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]\n multi-pwsh uninstall [--scope ] [--root ] [--force]\n multi-pwsh list [--scope ] [--root ] [--available] [--include-prerelease] [--offline-cache ]\n multi-pwsh package install [options]\n multi-pwsh package uninstall [--scope ] [--root ] [--force]\n multi-pwsh package list [--scope ] [--root ]\n multi-pwsh cache warm [--os ] [--arch ] [--include-prerelease] [--output ] [--product ]\n multi-pwsh venv create \n multi-pwsh venv delete \n multi-pwsh venv export \n multi-pwsh venv import \n multi-pwsh venv list\n multi-pwsh alias set \n multi-pwsh alias set \n multi-pwsh alias unset \n multi-pwsh host [-VirtualEnvironment |-venv ] [pwsh arguments...]\n multi-pwsh doctor --repair-aliases\n\nCommands:\n install, update, uninstall, list, package, cache, venv, alias, host, doctor, version" + "Usage:\n multi-pwsh --version\n multi-pwsh -V\n multi-pwsh --help\n multi-pwsh help [command]\n multi-pwsh install [--scope ] [--root ] [--arch ] [--include-prerelease] [--offline-cache ] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]\n multi-pwsh update [--scope ] [--root ] [--arch ] [--include-prerelease] [--offline-cache ] [--add-path|--no-add-path] [--register-manifest|--no-register-manifest] [--enable-psremoting] [--disable-telemetry] [--add-explorer-context-menu] [--add-file-context-menu]\n multi-pwsh uninstall [--scope ] [--root ] [--force]\n multi-pwsh list [--scope ] [--root ] [--available] [--include-prerelease] [--offline-cache ]\n multi-pwsh cache warm [--os ] [--arch ] [--include-prerelease] [--output ] [--product ]\n multi-pwsh venv create \n multi-pwsh venv delete \n multi-pwsh venv export \n multi-pwsh venv import \n multi-pwsh venv list\n multi-pwsh alias set \n multi-pwsh alias set \n multi-pwsh alias unset \n multi-pwsh host [-VirtualEnvironment |-venv ] [pwsh arguments...]\n multi-pwsh doctor --repair-aliases\n\nCommands:\n install, update, uninstall, list, cache, venv, alias, host, doctor, version" } fn print_usage() { @@ -90,32 +88,34 @@ fn is_help_flag(value: &str) -> bool { fn help_topic_text(topic: &str) -> Option<&'static str> { match topic { "install" => Some( - "Usage:\n multi-pwsh install [options]\n\nOptions:\n --scope \n --root \n --arch \n --include-prerelease\n --offline-cache \n --add-path | --no-add-path\n --register-manifest | --no-register-manifest\n --enable-psremoting\n --disable-telemetry\n --add-explorer-context-menu\n --add-file-context-menu\n --skip-hash-verification\n --hash-file ", + "Usage:\n multi-pwsh install [options]\n\nOptions:\n --scope \n --root \n --arch \n --include-prerelease\n --offline-cache \n --add-path | --no-add-path\n --register-manifest | --no-register-manifest\n --enable-psremoting\n --disable-telemetry\n --add-explorer-context-menu\n --add-file-context-menu\n --skip-hash-verification\n --hash-file \n\nNotes:\n User scope is the default when --scope is omitted.\n MULTI_PWSH_* path env vars affect only the default user layout.\n Machine scope uses platform defaults unless --root overrides the install root.\n --root requires --scope and does not mix in MULTI_PWSH_* child-dir overrides.\n On Windows, --add-path updates persistent User/Machine PATH; machine scope may require elevation for install roots, registry integrations, and Machine PATH updates.\n On macOS/Linux, --add-path is unsupported, --no-add-path is a no-op, and shell/profile PATH updates are manual.", ), "update" => Some( - "Usage:\n multi-pwsh update [options]\n\nOptions:\n --scope \n --root \n --arch \n --include-prerelease\n --offline-cache \n --add-path | --no-add-path\n --register-manifest | --no-register-manifest\n --enable-psremoting\n --disable-telemetry\n --add-explorer-context-menu\n --add-file-context-menu\n --skip-hash-verification\n --hash-file ", + "Usage:\n multi-pwsh update [options]\n\nOptions:\n --scope \n --root \n --arch \n --include-prerelease\n --offline-cache \n --add-path | --no-add-path\n --register-manifest | --no-register-manifest\n --enable-psremoting\n --disable-telemetry\n --add-explorer-context-menu\n --add-file-context-menu\n --skip-hash-verification\n --hash-file \n\nNotes:\n User scope is the default when --scope is omitted.\n MULTI_PWSH_* path env vars affect only the default user layout.\n Machine scope uses platform defaults unless --root overrides the install root.\n --root requires --scope and does not mix in MULTI_PWSH_* child-dir overrides.\n On Windows, --add-path updates persistent User/Machine PATH; machine scope may require elevation for install roots, registry integrations, and Machine PATH updates.\n On macOS/Linux, --add-path is unsupported, --no-add-path is a no-op, and shell/profile PATH updates are manual.", ), "uninstall" => Some( - "Usage:\n multi-pwsh uninstall [options]\n\nOptions:\n --scope \n --root \n --force", + "Usage:\n multi-pwsh uninstall [options]\n\nOptions:\n --scope \n --root \n --force\n\nNotes:\n User scope is the default when --scope is omitted.\n --root requires --scope .", ), "list" => Some( - "Usage:\n multi-pwsh list [options]\n\nOptions:\n --scope \n --root \n --available\n --include-prerelease\n --offline-cache ", + "Usage:\n multi-pwsh list [options]\n\nOptions:\n --scope \n --root \n --available\n --include-prerelease\n --offline-cache \n\nNotes:\n User scope is the default when --scope is omitted.\n --root requires --scope .\n Installed listings include prerelease versions; --include-prerelease only changes --available listings.", ), "venv" => Some( - "Usage:\n multi-pwsh venv create \n multi-pwsh venv delete \n multi-pwsh venv export \n multi-pwsh venv import \n multi-pwsh venv list", + "Usage:\n multi-pwsh venv create \n multi-pwsh venv delete \n multi-pwsh venv export \n multi-pwsh venv import \n multi-pwsh venv list\n\nNotes:\n Virtual environments live in the default user layout.", ), "alias" => Some( - "Usage:\n multi-pwsh alias set \n multi-pwsh alias set \n multi-pwsh alias unset ", + "Usage:\n multi-pwsh alias set \n multi-pwsh alias set \n multi-pwsh alias unset \n\nNotes:\n Direct alias commands operate on the default user layout; machine-scope aliases are normally entered through generated machine-scope shims.", ), "host" => Some( - "Usage:\n multi-pwsh host [-VirtualEnvironment |-venv ] [pwsh arguments...]\n multi-pwsh host -mcp -McpCommands [command ...] [-VirtualEnvironment |-venv ]", + "Usage:\n multi-pwsh host [-VirtualEnvironment |-venv ] [pwsh arguments...]\n multi-pwsh host -mcp -McpCommands [command ...] [-VirtualEnvironment |-venv ]\n\nNotes:\n Direct host commands resolve against the default user layout; generated machine-scope shims carry their own layout hints.", + ), + "doctor" => Some( + "Usage:\n multi-pwsh doctor --repair-aliases\n\nNotes:\n Direct doctor commands repair the default user layout; generated machine-scope shims carry their own layout hints.", ), - "doctor" => Some("Usage:\n multi-pwsh doctor --repair-aliases"), "cache" => Some( - "Usage:\n multi-pwsh cache warm [options]\n\nOptions:\n --os \n --arch \n --include-prerelease\n --output \n --product ", + "Usage:\n multi-pwsh cache warm [options]\n\nOptions:\n --os \n --arch \n --include-prerelease\n --output \n --product \n\nNotes:\n If --output is omitted, cache warm writes to MULTI_PWSH_CACHE_DIR or the default user cache directory.", ), "package" => Some( - "Usage:\n multi-pwsh package install [options]\n multi-pwsh package uninstall [--scope ] [--root ] [--force]\n multi-pwsh package list [--scope ] [--root ]", + "Usage:\n multi-pwsh package install [options]\n multi-pwsh package uninstall [--scope ] [--root ] [--force]\n multi-pwsh package list [--scope ] [--root ]\n\nAdvanced compatibility command; prefer the top-level install, update, uninstall, and list commands.\n\nNotes:\n MULTI_PWSH_* path env vars affect only the default user layout.\n Machine scope uses platform defaults unless --root overrides the install root.\n --root requires --scope .", ), "version" => Some("Usage:\n multi-pwsh --version\n multi-pwsh -V\n multi-pwsh version"), _ => None, @@ -148,6 +148,7 @@ fn run_help(args: &[String]) -> Result<()> { } } +#[cfg(test)] struct ReleaseSelectionOptions { arch: Option, include_prerelease: bool, @@ -178,8 +179,8 @@ enum WindowsListScope { impl WindowsListScope { fn parse(value: &str) -> Option { match value.to_ascii_lowercase().as_str() { - "currentuser" | "current-user" | "user" => Some(WindowsListScope::CurrentUser), - "allusers" | "all-users" | "machine" | "system" => Some(WindowsListScope::AllUsers), + "user" => Some(WindowsListScope::CurrentUser), + "machine" => Some(WindowsListScope::AllUsers), "all" => Some(WindowsListScope::All), _ => None, } @@ -858,11 +859,13 @@ fn import_virtual_environment_from_archive(venv_dir: &Path, archive_path: &Path) Ok(()) } -fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_args: Vec) -> Result { +fn run_known_host_executable( + executable: &Path, + layout: Option<&InstallLayout>, + selector_input: &str, + pwsh_args: Vec, +) -> Result { let os = HostOs::detect()?; - layout.ensure_base_dirs()?; - - let (_version, executable) = resolve_host_executable(&layout, selector_input)?; let HostDispatchOptions { launch, mcp } = preprocess_host_args(pwsh_args)?; let HostLaunchOptions { pwsh_args, @@ -872,7 +875,12 @@ fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_a let _virtual_environment_guards = virtual_environment .as_deref() - .map(|name| resolve_virtual_environment_dir(&layout, name)) + .map(|name| { + let layout = layout.ok_or_else(|| { + MultiPwshError::InvalidArguments("-VirtualEnvironment requires a multi-pwsh layout".to_string()) + })?; + resolve_virtual_environment_dir(layout, name) + }) .transpose()? .map(|venv_dir| configure_virtual_environment_host_env(os, &venv_dir)) .transpose()?; @@ -885,7 +893,7 @@ fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_a )); } - return mcp::run_stdio_mcp_server(&executable, &mcp.commands).map_err(|error| { + return mcp::run_stdio_mcp_server(executable, &mcp.commands).map_err(|error| { MultiPwshError::Host(format!( "failed to start MCP host for selector '{}': {}", selector_input, error @@ -899,7 +907,7 @@ fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_a (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_exe(executable, pwsh_args).map_err(|error| { MultiPwshError::Host(format!( "failed to start native host for selector '{}': {}", selector_input, error @@ -907,6 +915,13 @@ fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_a }) } +fn run_host_mode_with_layout(layout: InstallLayout, selector_input: &str, pwsh_args: Vec) -> Result { + layout.ensure_base_dirs()?; + + let (_version, executable) = resolve_host_executable(&layout, selector_input)?; + run_known_host_executable(&executable, Some(&layout), selector_input, pwsh_args) +} + fn run_host_mode(selector_input: &str, pwsh_args: Vec) -> Result { let os = HostOs::detect()?; let layout = default_current_user_layout(os)?; @@ -961,6 +976,24 @@ fn detect_implicit_host_selector(bin_dir: &Path, executable_path: &Path) -> Opti Some(selector) } +fn is_exact_pwsh_executable_name(executable_path: &Path) -> bool { + executable_selector_name(executable_path) + .map(|selector| selector.eq_ignore_ascii_case("pwsh")) + .unwrap_or(false) +} + +fn is_local_pwsh_apphost(executable_path: &Path) -> bool { + if !is_exact_pwsh_executable_name(executable_path) { + return false; + } + + let Some(executable_dir) = executable_path.parent() else { + return false; + }; + + executable_dir.join("pwsh.dll").is_file() && executable_dir.join("pwsh.runtimeconfig.json").is_file() +} + fn infer_layout_from_host_shim(os: HostOs, executable_path: &Path) -> Option { let selector = executable_selector_name(executable_path)?; if selector.eq_ignore_ascii_case("multi-pwsh") || !is_supported_alias_command(&selector) { @@ -997,6 +1030,14 @@ fn infer_layout_from_host_shim(os: HostOs, executable_path: &Path) -> Option Result> { let executable_path = env::current_exe()?; + let args: Vec = env::args_os().skip(1).collect(); + if is_local_pwsh_apphost(&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)?; + return Ok(Some(exit_code)); + } + let selector_name = match executable_selector_name(&executable_path) { Some(selector_name) => selector_name, None => return Ok(None), @@ -1011,7 +1052,6 @@ fn run_implicit_host_mode_if_needed() -> Result> { }; let selector = selector_name; - let args: Vec = env::args_os().skip(1).collect(); let exit_code = run_host_mode_with_layout(layout, &selector, args)?; Ok(Some(exit_code)) } @@ -1206,28 +1246,17 @@ fn parse_update_selector(value: &str) -> Result { "stable" => Ok(VersionSelector::Stable), "preview" => Ok(VersionSelector::Preview), "lts" => Ok(VersionSelector::Lts), - _ => parse_major_minor_selector(value).map(VersionSelector::MajorMinor), + _ => parse_major_minor_selector(value).map(VersionSelector::MajorMinor).map_err(|_| { + MultiPwshError::InvalidArguments(format!( + "update accepts stable, preview, lts, or a major.minor selector; use `multi-pwsh install {}` for exact versions, major selectors, or wildcard selectors", + value + )) + }), } } fn offline_cache_from_env() -> Option { - if let Some(path) = env::var_os(MULTI_PWSH_OFFLINE_CACHE_ENV_VAR) { - if !path.is_empty() { - return Some(PathBuf::from(path)); - } - } - - let value = env::var_os(MULTI_PWSH_RELEASE_SOURCE_ENV_VAR)?; - if value.is_empty() { - return None; - } - - let text = value.to_string_lossy(); - if text.eq_ignore_ascii_case("github") || text.eq_ignore_ascii_case("online") { - return None; - } - - Some(PathBuf::from(value)) + path_env_var(MULTI_PWSH_OFFLINE_CACHE_ENV_VAR) } fn effective_offline_cache(cli_value: Option) -> Option { @@ -1602,13 +1631,11 @@ fn run_cache_warm(selector_input: &str, options: CacheWarmOptions) -> Result<()> let selector = parse_install_selector(selector_input)?; let os = HostOs::detect().unwrap_or(HostOs::Windows); let output_root = options.output.unwrap_or_else(|| { - env::var_os("MULTI_PWSH_CACHE_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| { - default_current_user_layout(os) - .map(|layout| layout.cache_dir()) - .unwrap_or_else(|_| PathBuf::from(".")) - }) + path_env_var("MULTI_PWSH_CACHE_DIR").unwrap_or_else(|| { + default_current_user_layout(os) + .map(|layout| layout.cache_dir()) + .unwrap_or_else(|_| PathBuf::from(".")) + }) }); fs::create_dir_all(&output_root)?; @@ -1931,6 +1958,7 @@ fn run_alias(args: &[String]) -> Result<()> { } } +#[cfg(test)] fn parse_release_selection_options(args: &[String]) -> Result { let mut arch = None; let mut arch_specified = false; @@ -2113,15 +2141,33 @@ fn parse_package_layout_options(args: &[String]) -> Result } } + if root.is_some() && !scope_specified { + return Err(MultiPwshError::InvalidArguments( + "--root requires --scope ".to_string(), + )); + } + Ok(PackageLayoutOptions { scope, root }) } fn parse_package_install_options(args: &[String]) -> Result { let os = HostOs::detect()?; + parse_package_install_options_for_os(args, os) +} + +fn parse_package_install_options_for_os(args: &[String], os: HostOs) -> Result { let mut options = PackageInstallOptions::with_platform_defaults(PackageScope::CurrentUser, os); let mut scope_specified = false; let mut root_specified = false; let mut arch_specified = false; + let mut add_path_specified = false; + let mut register_manifest_specified = false; + let mut enable_psremoting_specified = false; + let mut disable_telemetry_specified = false; + let mut add_explorer_context_menu_specified = false; + let mut add_file_context_menu_specified = false; + let mut use_mu_specified = false; + let mut enable_mu_specified = false; let mut checksum_source = ChecksumSource::ReleaseAsset; let mut checksum_source_specified = false; let mut offline_cache = None; @@ -2160,13 +2206,35 @@ fn parse_package_install_options(args: &[String]) -> Result Result { + if os != HostOs::Windows { + return Err(MultiPwshError::InvalidArguments( + "--add-path is supported only on Windows; add the alias bin directory to PATH manually on macOS/Linux" + .to_string(), + )); + } options.add_path = true; + add_path_specified = true; index += 1; } "--no-add-path" => { options.add_path = false; + add_path_specified = true; index += 1; } "--register-manifest" => { options.register_manifest = true; + register_manifest_specified = true; index += 1; } "--no-register-manifest" => { options.register_manifest = false; + register_manifest_specified = true; index += 1; } "--enable-psremoting" => { options.enable_psremoting = true; + enable_psremoting_specified = true; index += 1; } "--disable-telemetry" => { options.disable_telemetry = true; + disable_telemetry_specified = true; index += 1; } "--add-explorer-context-menu" => { options.add_explorer_context_menu = true; + add_explorer_context_menu_specified = true; index += 1; } "--add-file-context-menu" => { options.add_file_context_menu = true; + add_file_context_menu_specified = true; index += 1; } "--use-mu" => { options.use_mu = true; + use_mu_specified = true; index += 1; } "--no-use-mu" => { options.use_mu = false; + use_mu_specified = true; index += 1; } "--enable-mu" => { options.enable_mu = true; + enable_mu_specified = true; index += 1; } "--no-enable-mu" => { options.enable_mu = false; + enable_mu_specified = true; index += 1; } _ => { @@ -2274,6 +2360,12 @@ fn parse_package_install_options(args: &[String]) -> Result for install and update".to_string(), + )); + } + options.validate(os)?; Ok(InstallCommandOptions { package: options, @@ -2282,28 +2374,6 @@ fn parse_package_install_options(args: &[String]) -> Result bool { - args.iter().any(|arg| { - matches!( - arg.as_str(), - "--scope" - | "--root" - | "--add-path" - | "--no-add-path" - | "--register-manifest" - | "--no-register-manifest" - | "--enable-psremoting" - | "--disable-telemetry" - | "--add-explorer-context-menu" - | "--add-file-context-menu" - | "--use-mu" - | "--no-use-mu" - | "--enable-mu" - | "--no-enable-mu" - ) - }) -} - fn parse_package_uninstall_options(args: &[String]) -> Result<(PackageLayoutOptions, bool)> { let mut scope = PackageScope::CurrentUser; let mut scope_specified = false; @@ -2363,6 +2433,12 @@ fn parse_package_uninstall_options(args: &[String]) -> Result<(PackageLayoutOpti } } + if root.is_some() && !scope_specified { + return Err(MultiPwshError::InvalidArguments( + "--root requires --scope ".to_string(), + )); + } + Ok((PackageLayoutOptions { scope, root }, force)) } @@ -2512,7 +2588,24 @@ fn run_package_install(selector_input: &str, install_options: InstallCommandOpti refresh_special_aliases(&layout, os)?; println!("Alias bin: {}", layout.bin_dir().display()); - println!("Add to PATH once for this scope: {}", layout.bin_dir().display()); + match os { + HostOs::Windows if options.add_path => { + println!( + "PATH entry updated for scope {}: {}", + options.scope.display_name(), + layout.bin_dir().display() + ); + } + HostOs::Windows => { + println!( + "PATH update skipped; add manually if needed: {}", + layout.bin_dir().display() + ); + } + HostOs::Linux | HostOs::Macos => { + println!("Add to PATH manually for this scope: {}", layout.bin_dir().display()); + } + } Ok(()) } @@ -2578,15 +2671,13 @@ fn run_package_list(layout_options: PackageLayoutOptions) -> Result<()> { println!(" - {}", record.version); println!(" path: {}", record.record.install_dir); println!( - " options: add_path={}, register_manifest={}, enable_psremoting={}, disable_telemetry={}, add_explorer_context_menu={}, add_file_context_menu={}, use_mu={}, enable_mu={}", + " options: add_path={}, register_manifest={}, enable_psremoting={}, disable_telemetry={}, add_explorer_context_menu={}, add_file_context_menu={}", record.record.add_path, record.record.register_manifest, record.record.enable_psremoting, record.record.disable_telemetry, record.record.add_explorer_context_menu, - record.record.add_file_context_menu, - record.record.use_mu, - record.record.enable_mu + record.record.add_file_context_menu ); } @@ -2595,68 +2686,44 @@ fn run_package_list(layout_options: PackageLayoutOptions) -> Result<()> { Ok(()) } -fn package_scope_has_version(scope: PackageScope, version: &Version) -> Result { - let os = HostOs::detect()?; - let arch = HostArch::detect(); - let layout = package_layout(os, arch, scope, None)?; - let metadata = load_package_metadata(&layout)?; - let in_metadata = metadata - .resolved_records()? - .into_iter() - .any(|record| record.version == *version); - Ok(in_metadata || layout.version_executable(version).exists()) -} - -fn resolve_scoped_uninstall_scope( - current_user_installed: bool, - all_users_installed: bool, -) -> Result> { - match (current_user_installed, all_users_installed) { - (true, true) => Err(MultiPwshError::InvalidArguments( - "the requested version is installed in both user and machine scopes; rerun with --scope " - .to_string(), - )), - (true, false) => Ok(Some(PackageScope::CurrentUser)), - (false, true) => Ok(Some(PackageScope::AllUsers)), - (false, false) => Ok(None), +fn package_version_present(layout: &InstallLayout, version: &Version) -> Result { + if layout.version_dir(version).exists() { + return Ok(true); } + + let metadata = load_package_metadata(layout)?; + Ok(metadata + .resolved_records()? + .iter() + .any(|record| record.version == *version)) } fn run_scoped_uninstall(version_input: &str, options: WindowsUninstallOptions) -> Result<()> { - let version = parse_exact_version(version_input)?; - let os = HostOs::detect()?; - if options.root.is_some() && options.scope.is_none() { return Err(MultiPwshError::InvalidArguments( "--root requires --scope for uninstall".to_string(), )); } - let Some(scope) = (match options.scope { - Some(scope) => Some(scope), - None => resolve_scoped_uninstall_scope( - package_scope_has_version(PackageScope::CurrentUser, &version)?, - package_scope_has_version(PackageScope::AllUsers, &version)?, - )?, - }) else { - if options.force { - println!( - "PowerShell {} is not installed in user or machine scopes; continuing because --force was provided", - version - ); - return Ok(()); - } - - return Err(MultiPwshError::InvalidArguments(format!( - "version {} is not installed in user or machine scopes (use --force to ignore)", - version - ))); - }; + if options.scope.is_none() && options.root.is_none() { + let version = parse_exact_version(version_input)?; + let os = HostOs::detect()?; + let arch = HostArch::detect(); + let user_layout = package_layout(os, arch, PackageScope::CurrentUser, None)?; - if os != HostOs::Windows && scope == PackageScope::CurrentUser && options.root.is_none() { - return run_uninstall(version_input, options.force); + if !package_version_present(&user_layout, &version)? { + let machine_layout = package_layout(os, arch, PackageScope::AllUsers, None)?; + if package_version_present(&machine_layout, &version)? { + return Err(MultiPwshError::InvalidArguments(format!( + "version {} is not installed in scope user but is installed in scope machine; rerun with --scope machine to uninstall it", + version + ))); + } + } } + let scope = options.scope.unwrap_or(PackageScope::CurrentUser); + run_package_uninstall( version_input, PackageLayoutOptions { @@ -2667,34 +2734,6 @@ fn run_scoped_uninstall(version_input: &str, options: WindowsUninstallOptions) - ) } -fn run_current_user_list(os: HostOs, root: Option) -> Result<()> { - let layout = match root { - Some(root) => package_layout(os, HostArch::detect(), PackageScope::CurrentUser, Some(root))?, - None => InstallLayout::new(os)?, - }; - let versions = layout.installed_versions()?; - - println!("Home: {}", layout.home().display()); - println!("Alias bin: {}", layout.bin_dir().display()); - println!("Versions dir: {}", layout.versions_dir().display()); - println!("Venv dir: {}", layout.venvs_dir().display()); - println!("Cache dir: {}", layout.cache_dir().display()); - println!(); - - if versions.is_empty() { - println!("Installed versions: (none)"); - } else { - println!("Installed versions:"); - for version in versions { - println!(" - {}", version); - } - } - - print_alias_metadata(&layout)?; - - Ok(()) -} - fn format_special_alias_policy_line(alias_name: &str, policy_text: &str, aliases: &HashMap) -> String { match aliases.get(alias_name) { Some(version) => format!(" - {} follows {} -> {}", alias_name, policy_text, version), @@ -2746,30 +2785,25 @@ fn print_alias_metadata(layout: &InstallLayout) -> Result<()> { Ok(()) } -fn run_scoped_list_scope(os: HostOs, scope: PackageScope, root: Option) -> Result<()> { - if os != HostOs::Windows && scope == PackageScope::CurrentUser { - return run_current_user_list(os, root); - } - +fn run_scoped_list_scope(scope: PackageScope, root: Option) -> Result<()> { run_package_list(PackageLayoutOptions { scope, root }) } fn run_scoped_list(scope: Option, root: Option) -> Result<()> { - let os = HostOs::detect()?; - - match scope.unwrap_or(WindowsListScope::CurrentUser) { - WindowsListScope::CurrentUser => run_scoped_list_scope(os, PackageScope::CurrentUser, root), - WindowsListScope::AllUsers => run_scoped_list_scope(os, PackageScope::AllUsers, root), - WindowsListScope::All => { + match scope { + None => run_scoped_list_scope(PackageScope::CurrentUser, root), + Some(WindowsListScope::CurrentUser) => run_scoped_list_scope(PackageScope::CurrentUser, root), + Some(WindowsListScope::AllUsers) => run_scoped_list_scope(PackageScope::AllUsers, root), + Some(WindowsListScope::All) => { if root.is_some() { return Err(MultiPwshError::InvalidArguments( "--root cannot be used with --scope all".to_string(), )); } - run_scoped_list_scope(os, PackageScope::CurrentUser, None)?; + run_scoped_list_scope(PackageScope::CurrentUser, None)?; println!(); - run_scoped_list_scope(os, PackageScope::AllUsers, None) + run_scoped_list_scope(PackageScope::AllUsers, None) } } } @@ -2785,7 +2819,7 @@ fn run_package(args: &[String]) -> Result<()> { "install" => { if args.len() < 2 { return Err(MultiPwshError::InvalidArguments( - "package install requires ".to_string(), + "package install requires ".to_string(), )); } @@ -2812,137 +2846,6 @@ fn run_package(args: &[String]) -> Result<()> { } } -fn run_install( - selector_input: &str, - arch: Option, - include_prerelease: bool, - checksum_source: ChecksumSource, - offline_cache: Option, -) -> Result<()> { - let selector = parse_install_selector(selector_input)?; - let os = HostOs::detect()?; - let arch = arch.unwrap_or_else(HostArch::detect); - - let layout = InstallLayout::new(os)?; - layout.ensure_base_dirs()?; - - let release_resolver = ReleaseResolver::new(offline_cache)?; - let releases = match selector.clone() { - VersionSelector::MajorMinorWildcard(line) => { - release_resolver.resolve_all_in_line(line, os, arch, include_prerelease)? - } - _ => vec![release_resolver.resolve_selector(selector.clone(), os, arch, include_prerelease)?], - }; - - let mut touched_lines: Vec = Vec::new(); - let mut touched_majors: Vec = Vec::new(); - - for release in releases { - let executable_path = - ensure_installed(&layout, release_resolver.http_client(), os, &release, &checksum_source)?; - let patch_alias = create_or_update_patch_alias(&layout, os, &release.version, &executable_path)?; - let version_path = executable_path.parent().unwrap_or_else(|| Path::new("")); - - println!("Installed PowerShell {}", release.version); - println!("Version path: {}", version_path.display()); - println!("Updated patch alias: {}", patch_alias.display()); - - let line = release.version_line(); - if !touched_lines.contains(&line) { - touched_lines.push(line); - } - if !touched_majors.contains(&release.version.major) { - touched_majors.push(release.version.major); - } - } - - touched_lines.sort(); - touched_majors.sort(); - - for line in touched_lines { - let pinned = read_minor_pin(&layout, line)?; - let alias_path = sync_minor_alias(&layout, os, line)?; - match alias_path { - Some(path) => println!("Updated alias: {}", path.display()), - None if pinned.is_some() => { - println!( - "Alias pwsh-{}.{} remains pinned but unresolved (target is not installed)", - line.major, line.minor - ); - } - None => {} - } - } - - for major in touched_majors { - let major_alias_path = latest_installed_in_major(&layout, major)? - .map(|version| { - let target = layout.version_executable(&version); - create_or_update_major_alias(&layout, os, version.major, &version, &target) - }) - .transpose()?; - - if let Some(path) = major_alias_path { - println!("Updated major alias: {}", path.display()); - } - } - - ensure_default_special_policy(&layout, &selector)?; - refresh_special_aliases(&layout, os)?; - - println!("Add to PATH once: {}", layout.bin_dir().display()); - - Ok(()) -} - -fn run_update( - line_input: &str, - arch: Option, - include_prerelease: bool, - checksum_source: ChecksumSource, - offline_cache: Option, -) -> Result<()> { - let line = parse_major_minor_selector(line_input)?; - let os = HostOs::detect()?; - let arch = arch.unwrap_or_else(HostArch::detect); - - let layout = InstallLayout::new(os)?; - layout.ensure_base_dirs()?; - - let release_resolver = ReleaseResolver::new(offline_cache)?; - let release = release_resolver.resolve_selector(VersionSelector::MajorMinor(line), os, arch, include_prerelease)?; - let executable_path = ensure_installed(&layout, release_resolver.http_client(), os, &release, &checksum_source)?; - let patch_alias_path = create_or_update_patch_alias(&layout, os, &release.version, &executable_path)?; - let version_path = executable_path.parent().unwrap_or_else(|| Path::new("")); - - let alias_path = sync_minor_alias(&layout, os, line)?; - let major_alias_path = latest_installed_in_major(&layout, release.version.major)? - .map(|version| { - let target = layout.version_executable(&version); - create_or_update_major_alias(&layout, os, version.major, &version, &target) - }) - .transpose()?; - - println!("Updated line {} to {}", line, release.version); - println!("Version path: {}", version_path.display()); - println!("Updated patch alias: {}", patch_alias_path.display()); - if let Some(path) = alias_path { - println!("Updated alias: {}", path.display()); - } else if read_minor_pin(&layout, line)?.is_some() { - println!( - "Alias pwsh-{}.{} remains pinned but unresolved (target is not installed)", - line.major, line.minor - ); - } - if let Some(path) = major_alias_path { - println!("Updated major alias: {}", path.display()); - } - refresh_special_aliases(&layout, os)?; - println!("Add to PATH once: {}", layout.bin_dir().display()); - - Ok(()) -} - fn cleanup_aliases_for_removed_version(layout: &InstallLayout, os: HostOs, version: &Version) -> Result<()> { let aliases = aliases::read_alias_metadata(layout)?; let removed_version_text = version.to_string(); @@ -3114,12 +3017,6 @@ fn parse_list_option(args: &[String]) -> Result { } } - if include_prerelease && !available { - return Err(MultiPwshError::InvalidArguments( - "--include-prerelease requires --available".to_string(), - )); - } - if available { if scope.is_some() || root.is_some() { return Err(MultiPwshError::InvalidArguments( @@ -3141,30 +3038,6 @@ fn parse_list_option(args: &[String]) -> Result { Ok(ListOption::Installed { scope, root }) } -fn run_uninstall(version_input: &str, force: bool) -> Result<()> { - let version = parse_exact_version(version_input)?; - let os = HostOs::detect()?; - - let layout = InstallLayout::new(os)?; - layout.ensure_base_dirs()?; - - if layout.remove_version_dirs(&version)? { - println!("Removed PowerShell {}", version); - } else if force { - println!( - "PowerShell {} is not installed; continuing because --force was provided", - version - ); - } else { - return Err(MultiPwshError::InvalidArguments(format!( - "version {} is not installed (use --force to ignore)", - version - ))); - } - - cleanup_aliases_for_removed_version(&layout, os, &version) -} - fn run_list(option: ListOption) -> Result<()> { match option { ListOption::Installed { scope, root } => { @@ -3343,8 +3216,6 @@ fn run() -> Result<()> { return print_help_topic(&args[0]); } - let os = HostOs::detect()?; - match args[0].as_str() { "install" => { if args.len() < 2 { @@ -3352,19 +3223,9 @@ fn run() -> Result<()> { "install requires ".to_string(), )); } - if os == HostOs::Windows || requires_scoped_install_backend(&args[2..]) { - let options = parse_package_install_options(&args[2..])?; - run_package_install(&args[1], options) - } else { - let options = parse_release_selection_options(&args[2..])?; - run_install( - &args[1], - options.arch, - options.include_prerelease, - options.checksum_source, - options.offline_cache, - ) - } + + let options = parse_package_install_options(&args[2..])?; + run_package_install(&args[1], options) } "update" => { if args.len() < 2 { @@ -3372,30 +3233,9 @@ fn run() -> Result<()> { "update requires ".to_string(), )); } - let update_selector = parse_update_selector(&args[1])?; - - if os == HostOs::Windows || requires_scoped_install_backend(&args[2..]) { - let options = parse_package_install_options(&args[2..])?; - run_package_install(&args[1], options) - } else if matches!(update_selector, VersionSelector::MajorMinor(_)) { - let options = parse_release_selection_options(&args[2..])?; - run_update( - &args[1], - options.arch, - options.include_prerelease, - options.checksum_source, - options.offline_cache, - ) - } else { - let options = parse_release_selection_options(&args[2..])?; - run_install( - &args[1], - options.arch, - options.include_prerelease, - options.checksum_source, - options.offline_cache, - ) - } + parse_update_selector(&args[1])?; + let options = parse_package_install_options(&args[2..])?; + run_package_install(&args[1], options) } "uninstall" => { if args.len() < 2 { @@ -3420,7 +3260,7 @@ fn run() -> Result<()> { } "doctor" => run_doctor(&args[1..]), command => Err(MultiPwshError::InvalidArguments(format!( - "unknown command '{}'. expected: install, update, uninstall, list, cache, venv, alias, host, doctor, package, version", + "unknown command '{}'. expected: install, update, uninstall, list, cache, venv, alias, host, doctor, version", command ))), } @@ -3488,6 +3328,29 @@ mod tests { result } + fn with_env_var_texts(values: &[(&str, Option<&str>)], action: impl FnOnce() -> T) -> T { + let _guard = crate::TEST_ENV_LOCK.lock().unwrap(); + let previous: Vec<_> = values.iter().map(|(key, _)| (*key, env::var_os(key))).collect(); + + for (key, value) in values { + match value { + Some(value) => unsafe { env::set_var(*key, value) }, + None => unsafe { env::remove_var(*key) }, + } + } + + let result = action(); + + for (key, value) in previous { + match value { + Some(value) => unsafe { env::set_var(key, value) }, + None => unsafe { env::remove_var(key) }, + } + } + + result + } + #[test] fn default_current_user_layout_uses_user_home_layout_on_windows() { let temp_dir = TempDir::new().unwrap(); @@ -3623,9 +3486,15 @@ mod tests { } #[test] - fn parse_list_option_rejects_prerelease_without_available() { + fn parse_list_option_accepts_prerelease_for_installed_listing() { let args = vec!["--include-prerelease".to_string()]; - assert!(parse_list_option(&args).is_err()); + assert!(matches!( + parse_list_option(&args).unwrap(), + ListOption::Installed { + scope: None, + root: None + } + )); } #[test] @@ -3640,6 +3509,14 @@ mod tests { )); } + #[test] + fn parse_list_option_rejects_scope_aliases() { + for alias in ["current-user", "all-users", "system"] { + let args = vec!["--scope".to_string(), alias.to_string()]; + assert!(parse_list_option(&args).is_err(), "expected {} to be rejected", alias); + } + } + #[test] fn parse_list_option_rejects_scope_for_available() { let args = vec!["--available".to_string(), "--scope".to_string(), "machine".to_string()]; @@ -3677,6 +3554,177 @@ mod tests { assert_eq!(options.package.scope, PackageScope::CurrentUser); } + #[test] + fn parse_package_install_options_rejects_scope_aliases() { + for alias in ["current-user", "all-users", "system"] { + let args = vec!["--scope".to_string(), alias.to_string()]; + assert!( + parse_package_install_options_for_os(&args, HostOs::Windows).is_err(), + "expected {} to be rejected", + alias + ); + } + } + + #[test] + fn parse_package_install_options_rejects_add_path_on_unix() { + let args = vec!["--add-path".to_string()]; + let error = parse_package_install_options_for_os(&args, HostOs::Linux).unwrap_err(); + assert!(error.to_string().contains("supported only on Windows")); + } + + #[test] + fn parse_package_install_options_accepts_no_add_path_on_unix() { + let args = vec!["--no-add-path".to_string()]; + let options = parse_package_install_options_for_os(&args, HostOs::Linux).unwrap(); + + assert!(!options.package.add_path); + } + + #[test] + fn parse_package_install_options_requires_scope_with_root() { + let args = vec!["--root".to_string(), "C:\\PowerShell".to_string()]; + let error = parse_package_install_options_for_os(&args, HostOs::Windows).unwrap_err(); + assert!(error + .to_string() + .contains("--root requires --scope for install and update")); + + let args = vec![ + "--scope".to_string(), + "user".to_string(), + "--root".to_string(), + "C:\\PowerShell".to_string(), + ]; + assert!(parse_package_install_options_for_os(&args, HostOs::Windows).is_ok()); + } + + #[test] + fn parse_package_install_options_preserves_flags_before_scope() { + let args = vec![ + "--no-add-path".to_string(), + "--disable-telemetry".to_string(), + "--scope".to_string(), + "machine".to_string(), + ]; + let options = parse_package_install_options_for_os(&args, HostOs::Windows).unwrap(); + + assert_eq!(options.package.scope, PackageScope::AllUsers); + assert!(!options.package.add_path); + assert!(options.package.disable_telemetry); + assert!(options.package.register_manifest); + } + + #[test] + fn parse_package_install_options_applies_scope_defaults_when_flags_are_not_specified() { + let args = vec!["--scope".to_string(), "machine".to_string()]; + let options = parse_package_install_options_for_os(&args, HostOs::Windows).unwrap(); + + assert!(options.package.add_path); + assert!(options.package.register_manifest); + } + + #[test] + fn parse_package_layout_options_requires_scope_with_root() { + let args = vec!["--root".to_string(), "C:\\PowerShell".to_string()]; + let error = parse_package_layout_options(&args).unwrap_err(); + assert!(error.to_string().contains("--root requires --scope ")); + + let args = vec![ + "--scope".to_string(), + "machine".to_string(), + "--root".to_string(), + "C:\\PowerShell".to_string(), + ]; + assert!(parse_package_layout_options(&args).is_ok()); + } + + #[test] + fn parse_package_uninstall_options_requires_scope_with_root() { + let args = vec!["--root".to_string(), "C:\\PowerShell".to_string()]; + let error = parse_package_uninstall_options(&args).unwrap_err(); + assert!(error.to_string().contains("--root requires --scope ")); + + let args = vec![ + "--scope".to_string(), + "machine".to_string(), + "--root".to_string(), + "C:\\PowerShell".to_string(), + ]; + assert!(parse_package_uninstall_options(&args).is_ok()); + } + + #[test] + fn parse_package_install_options_defaults_add_path_by_platform() { + let args: Vec = Vec::new(); + + assert!( + parse_package_install_options_for_os(&args, HostOs::Windows) + .unwrap() + .package + .add_path + ); + assert!( + !parse_package_install_options_for_os(&args, HostOs::Linux) + .unwrap() + .package + .add_path + ); + } + + #[test] + fn parse_update_selector_reports_actionable_error_for_exact_versions() { + let error = parse_update_selector("7.4.13").unwrap_err(); + assert!(error.to_string().contains("update accepts stable, preview, lts")); + assert!(error.to_string().contains("multi-pwsh install 7.4.13")); + } + + #[cfg(windows)] + #[test] + fn default_uninstall_reports_machine_scope_when_user_scope_is_missing() { + let temp_dir = TempDir::new().unwrap(); + let user_home = temp_dir.path().join("user-home"); + let program_files = temp_dir.path().join("program-files"); + let machine_version_dir = program_files.join("PowerShell").join("7.4.13"); + fs::create_dir_all(&machine_version_dir).unwrap(); + + with_env_vars( + &[ + ("MULTI_PWSH_HOME", Some(user_home.as_path())), + ("ProgramFiles", Some(program_files.as_path())), + ("ProgramFiles(x86)", None), + ], + || { + let error = run_scoped_uninstall( + "7.4.13", + WindowsUninstallOptions { + scope: None, + root: None, + force: false, + }, + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("not installed in scope user")); + assert!(message.contains("installed in scope machine")); + assert!(message.contains("--scope machine")); + }, + ); + } + + #[test] + fn offline_cache_from_env_ignores_empty_and_whitespace_values() { + with_env_var_texts(&[(MULTI_PWSH_OFFLINE_CACHE_ENV_VAR, Some(" \t "))], || { + assert_eq!(offline_cache_from_env(), None); + }); + } + + #[test] + fn offline_cache_from_env_reads_offline_cache_value() { + with_env_var_texts(&[(MULTI_PWSH_OFFLINE_CACHE_ENV_VAR, Some("C:\\offline-cache"))], || { + assert_eq!(offline_cache_from_env(), Some(PathBuf::from("C:\\offline-cache"))); + }); + } + #[test] fn parse_cache_warm_options_accepts_cross_platform_all_products() { let args = vec![ @@ -3699,24 +3747,6 @@ mod tests { assert!(options.arch_wildcard); } - #[test] - fn resolve_scoped_uninstall_scope_prefers_unambiguous_scope() { - assert_eq!( - resolve_scoped_uninstall_scope(true, false).unwrap(), - Some(PackageScope::CurrentUser) - ); - assert_eq!( - resolve_scoped_uninstall_scope(false, true).unwrap(), - Some(PackageScope::AllUsers) - ); - assert_eq!(resolve_scoped_uninstall_scope(false, false).unwrap(), None); - } - - #[test] - fn resolve_scoped_uninstall_scope_rejects_ambiguous_version() { - assert!(resolve_scoped_uninstall_scope(true, true).is_err()); - } - #[test] fn parse_host_selector_supports_alias_name() { let selector = parse_host_selector("pwsh-7.4").unwrap(); @@ -3846,6 +3876,55 @@ mod tests { assert!(selector.is_none()); } + #[test] + fn is_local_pwsh_apphost_accepts_exact_pwsh_with_adjacent_sdk_payload() { + let temp_dir = TempDir::new().unwrap(); + let executable_path = temp_dir.path().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!(is_local_pwsh_apphost(&executable_path)); + } + + #[test] + fn is_local_pwsh_apphost_accepts_exact_pwsh_without_optional_payload_signals() { + let temp_dir = TempDir::new().unwrap(); + let executable_path = temp_dir.path().join("pwsh"); + 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)); + } + + #[test] + fn is_local_pwsh_apphost_rejects_alias_name_with_adjacent_payload() { + 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)); + } + + #[test] + fn is_local_pwsh_apphost_rejects_missing_required_payload_files() { + let temp_dir = TempDir::new().unwrap(); + let executable_path = temp_dir.path().join("pwsh.exe"); + fs::write(&executable_path, "").unwrap(); + + assert!(!is_local_pwsh_apphost(&executable_path)); + + fs::write(temp_dir.path().join("pwsh.dll"), "").unwrap(); + assert!(!is_local_pwsh_apphost(&executable_path)); + + 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)); + } + #[test] fn infer_layout_from_host_shim_uses_parent_of_bin_dir() { let executable_path = PathBuf::from("C:/Program Files/PowerShell/bin/pwsh-7.4.exe"); diff --git a/crates/multi-pwsh/src/package.rs b/crates/multi-pwsh/src/package.rs index 7c65d65..0902fe9 100644 --- a/crates/multi-pwsh/src/package.rs +++ b/crates/multi-pwsh/src/package.rs @@ -24,8 +24,8 @@ pub enum PackageScope { impl PackageScope { pub fn parse(value: &str) -> Option { match value.to_ascii_lowercase().as_str() { - "current-user" | "currentuser" | "user" => Some(PackageScope::CurrentUser), - "all-users" | "allusers" | "machine" | "system" => Some(PackageScope::AllUsers), + "user" => Some(PackageScope::CurrentUser), + "machine" => Some(PackageScope::AllUsers), _ => None, } } @@ -104,14 +104,15 @@ impl PackageInstallOptions { } pub fn with_platform_defaults(scope: PackageScope, os: HostOs) -> Self { - let privileged_defaults = os == HostOs::Windows && scope == PackageScope::AllUsers; + let windows = os == HostOs::Windows; + let privileged_defaults = windows && scope == PackageScope::AllUsers; Self { scope, arch: None, include_prerelease: false, install_root: None, - add_path: true, + add_path: windows, register_manifest: privileged_defaults, enable_psremoting: false, disable_telemetry: false, @@ -901,17 +902,18 @@ mod tests { } #[test] - fn package_scope_parses_aliases() { - assert_eq!(PackageScope::parse("current-user"), Some(PackageScope::CurrentUser)); - assert_eq!(PackageScope::parse("CurrentUser"), Some(PackageScope::CurrentUser)); - assert_eq!(PackageScope::parse("all-users"), Some(PackageScope::AllUsers)); - assert_eq!(PackageScope::parse("AllUsers"), Some(PackageScope::AllUsers)); + fn package_scope_parses_canonical_values() { + assert_eq!(PackageScope::parse("user"), Some(PackageScope::CurrentUser)); assert_eq!(PackageScope::parse("machine"), Some(PackageScope::AllUsers)); + assert_eq!(PackageScope::parse("current-user"), None); + assert_eq!(PackageScope::parse("all-users"), None); + assert_eq!(PackageScope::parse("system"), None); } #[test] fn current_user_defaults_disable_machine_actions() { let options = PackageInstallOptions::with_defaults(PackageScope::CurrentUser); + assert!(options.add_path); assert!(!options.register_manifest); assert!(!options.use_mu); assert!(!options.enable_mu); @@ -931,11 +933,18 @@ mod tests { #[test] fn all_users_unix_defaults_disable_windows_integrations() { let options = PackageInstallOptions::with_platform_defaults(PackageScope::AllUsers, HostOs::Linux); + assert!(!options.add_path); assert!(!options.register_manifest); assert!(!options.use_mu); assert!(!options.enable_mu); } + #[test] + fn current_user_unix_defaults_do_not_record_path_integration() { + let options = PackageInstallOptions::with_platform_defaults(PackageScope::CurrentUser, HostOs::Linux); + assert!(!options.add_path); + } + #[test] fn validate_rejects_windows_only_flags_on_unix() { let mut options = PackageInstallOptions::with_platform_defaults(PackageScope::AllUsers, HostOs::Linux); @@ -1071,6 +1080,39 @@ mod tests { ); } + #[test] + fn package_layout_user_explicit_root_ignores_multi_pwsh_env_overrides() { + let _guard = crate::TEST_ENV_LOCK.lock().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path().join("explicit-root"); + let ignored_home = temp_dir.path().join("ignored-home"); + let ignored_bin = temp_dir.path().join("ignored-bin"); + let ignored_cache = temp_dir.path().join("ignored-cache"); + let ignored_venv = temp_dir.path().join("ignored-venv"); + + with_env_var("MULTI_PWSH_HOME", Some(&ignored_home), || { + with_env_var("MULTI_PWSH_BIN_DIR", Some(&ignored_bin), || { + with_env_var("MULTI_PWSH_CACHE_DIR", Some(&ignored_cache), || { + with_env_var("MULTI_PWSH_VENV_DIR", Some(&ignored_venv), || { + let layout = package_layout( + HostOs::Linux, + HostArch::X64, + PackageScope::CurrentUser, + Some(root.clone()), + ) + .unwrap(); + + assert_eq!(layout.home(), root.as_path()); + assert_eq!(layout.bin_dir(), root.join("bin")); + assert_eq!(layout.cache_dir(), root.join("cache")); + assert_eq!(layout.venvs_dir(), root.join("venv")); + assert_eq!(layout.versions_dir(), root.join("multi")); + }) + }) + }) + }); + } + #[test] fn package_layout_windows_user_default_honors_explicit_overrides() { let _guard = crate::TEST_ENV_LOCK.lock().unwrap(); @@ -1189,4 +1231,35 @@ mod tests { assert_eq!(layout.bin_dir(), PathBuf::from("/usr/local/bin")); assert_eq!(layout.versions_dir(), PathBuf::from("/opt/microsoft/powershell")); } + + #[test] + fn package_layout_windows_machine_default_ignores_multi_pwsh_env_overrides() { + let _guard = crate::TEST_ENV_LOCK.lock().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let program_files = temp_dir.path().join("ProgramFiles"); + let ignored_home = temp_dir.path().join("ignored-home"); + let ignored_bin = temp_dir.path().join("ignored-bin"); + let ignored_cache = temp_dir.path().join("ignored-cache"); + let ignored_venv = temp_dir.path().join("ignored-venv"); + + with_env_var("ProgramFiles", Some(&program_files), || { + with_env_var("MULTI_PWSH_HOME", Some(&ignored_home), || { + with_env_var("MULTI_PWSH_BIN_DIR", Some(&ignored_bin), || { + with_env_var("MULTI_PWSH_CACHE_DIR", Some(&ignored_cache), || { + with_env_var("MULTI_PWSH_VENV_DIR", Some(&ignored_venv), || { + let layout = + package_layout(HostOs::Windows, HostArch::X64, PackageScope::AllUsers, None).unwrap(); + let root = program_files.join("PowerShell"); + + assert_eq!(layout.home(), root.as_path()); + assert_eq!(layout.bin_dir(), root.join("bin")); + assert_eq!(layout.cache_dir(), root.join("cache")); + assert_eq!(layout.venvs_dir(), root.join("venv")); + assert_eq!(layout.versions_dir(), root); + }) + }) + }) + }) + }); + } } diff --git a/crates/multi-pwsh/tests/cli_args.rs b/crates/multi-pwsh/tests/cli_args.rs index 6d35cbb..c5f54eb 100644 --- a/crates/multi-pwsh/tests/cli_args.rs +++ b/crates/multi-pwsh/tests/cli_args.rs @@ -95,8 +95,8 @@ fn top_level_help_prints_usage() { ); assert!(stdout.contains("multi-pwsh -V"), "unexpected stdout: {}", stdout); assert!( - stdout.contains("multi-pwsh package install [options]"), - "unexpected stdout: {}", + !stdout.contains("multi-pwsh package install"), + "package command should stay hidden from top-level help: {}", stdout ); assert!( @@ -134,9 +134,66 @@ fn subcommand_help_prints_focused_usage() { "unexpected stdout: {}", stdout ); + assert!( + stdout.contains("--root requires --scope "), + "unexpected stdout: {}", + stdout + ); + } +} + +#[test] +fn focused_help_documents_user_layout_and_cache_defaults() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + for (args, expected) in [ + (&["help", "host"][..], "default user layout"), + (&["help", "alias"][..], "default user layout"), + (&["help", "doctor"][..], "default user layout"), + (&["help", "venv"][..], "default user layout"), + (&["help", "cache"][..], "MULTI_PWSH_CACHE_DIR"), + ] { + let output = run_multi_pwsh(args, temp_dir.path()); + + assert!( + output.status.success(), + "expected {:?} to succeed: {}", + args, + normalize_output(&output.stderr) + ); + let stdout = normalize_output(&output.stdout); + assert!( + stdout.contains(expected), + "expected {:?} help to contain {:?}, got: {}", + args, + expected, + stdout + ); } } +#[test] +fn package_help_remains_available_as_advanced_command() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let output = run_multi_pwsh(&["help", "package"], temp_dir.path()); + + assert!( + output.status.success(), + "expected package help to succeed: {}", + normalize_output(&output.stderr) + ); + let stdout = normalize_output(&output.stdout); + assert!( + stdout.contains("multi-pwsh package install"), + "unexpected stdout: {}", + stdout + ); + assert!( + stdout.contains("Advanced compatibility command"), + "unexpected stdout: {}", + stdout + ); +} + #[test] fn version_command_help_prints_focused_usage() { let temp_dir = TempDir::new().expect("failed to create temp dir"); @@ -623,10 +680,15 @@ fn update_accepts_include_prerelease_flag() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("not a major.minor selector"), + stderr.contains("update accepts stable, preview, lts"), "expected selector parse error, got stderr: {}", stderr ); + assert!( + stderr.contains("multi-pwsh install not-a-line"), + "expected install suggestion, got stderr: {}", + stderr + ); } #[test] @@ -1260,6 +1322,125 @@ fn list_reports_named_alias_policy_resolution() { ); } +#[test] +fn list_without_scope_uses_user_package_listing() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let output = run_multi_pwsh(&["list"], temp_dir.path()); + + assert!( + output.status.success(), + "expected default list to succeed: {}", + normalize_output(&output.stderr) + ); + + let stdout = normalize_output(&output.stdout); + assert!(stdout.contains("Scope: user"), "unexpected stdout: {}", stdout); + assert!(stdout.contains("Metadata file:"), "unexpected stdout: {}", stdout); +} + +#[test] +fn list_include_prerelease_without_available_uses_installed_listing() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let output = run_multi_pwsh(&["list", "--include-prerelease"], temp_dir.path()); + + assert!( + output.status.success(), + "expected installed list to succeed: {}", + normalize_output(&output.stderr) + ); + + let stdout = normalize_output(&output.stdout); + assert!(stdout.contains("Scope: user"), "unexpected stdout: {}", stdout); +} + +#[test] +fn list_rejects_scope_aliases() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + for alias in ["current-user", "all-users", "system"] { + let output = run_multi_pwsh(&["list", "--scope", alias], temp_dir.path()); + + assert!(!output.status.success(), "expected {} to be rejected", alias); + let stderr = normalize_output(&output.stderr); + assert!( + stderr.contains("expected one of: user, machine, all"), + "unexpected stderr for {alias}: {}", + stderr + ); + } +} + +#[test] +fn uninstall_without_scope_targets_user_scope_only() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let output = run_multi_pwsh(&["uninstall", "7.4.13"], temp_dir.path()); + + assert!(!output.status.success(), "expected missing install to fail"); + let stderr = normalize_output(&output.stderr); + assert!( + stderr.contains("version 7.4.13 is not installed in scope user"), + "unexpected stderr: {}", + stderr + ); +} + +#[test] +fn uninstall_root_requires_explicit_scope() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let root = temp_dir.path().join("machine-root"); + let root_text = root.display().to_string(); + let output = run_multi_pwsh(&["uninstall", "7.4.13", "--root", &root_text], temp_dir.path()); + + assert!(!output.status.success(), "expected --root without scope to fail"); + let stderr = normalize_output(&output.stderr); + assert!( + stderr.contains("--root requires --scope for uninstall"), + "unexpected stderr: {}", + stderr + ); +} + +#[test] +fn install_and_update_root_require_explicit_scope() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let root = temp_dir.path().join("custom-root"); + let root_text = root.display().to_string(); + + for args in [ + &["install", "7.4", "--root", &root_text][..], + &["update", "7.4", "--root", &root_text][..], + ] { + let output = run_multi_pwsh(args, temp_dir.path()); + + assert!(!output.status.success(), "expected {:?} to fail", args); + let stderr = normalize_output(&output.stderr); + assert!( + stderr.contains("--root requires --scope for install and update"), + "unexpected stderr for {:?}: {}", + args, + stderr + ); + } +} + +#[test] +fn update_exact_version_error_suggests_install() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let output = run_multi_pwsh(&["update", "7.4.13"], temp_dir.path()); + + assert!(!output.status.success(), "expected exact update selector to fail"); + let stderr = normalize_output(&output.stderr); + assert!( + stderr.contains("update accepts stable, preview, lts"), + "unexpected stderr: {}", + stderr + ); + assert!( + stderr.contains("multi-pwsh install 7.4.13"), + "unexpected stderr: {}", + stderr + ); +} + #[cfg(windows)] #[test] fn install_rejects_machine_actions_for_current_user_scope() { diff --git a/crates/pwsh-host/Cargo.toml b/crates/pwsh-host/Cargo.toml index d7a3123..84ef26a 100644 --- a/crates/pwsh-host/Cargo.toml +++ b/crates/pwsh-host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pwsh-host" -version = "0.12.1" +version = "0.13.0" edition = "2018" license = "MIT/Apache-2.0" homepage = "https://github.com/Devolutions/pwsh-host-rs" diff --git a/crates/pwsh-host/src/hostfxr.rs b/crates/pwsh-host/src/hostfxr.rs index d47eb76..5143b28 100644 --- a/crates/pwsh-host/src/hostfxr.rs +++ b/crates/pwsh-host/src/hostfxr.rs @@ -1,12 +1,14 @@ use std::borrow::BorrowMut; use std::ffi::OsStr; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::{env, ptr}; use dlopen::wrapper::{Container, WrapperApi}; use crate::context::{HostfxrContext, HostfxrHandle, InitializedForCommandLine, InitializedForRuntimeConfig}; use crate::host_detect::pwsh_host_detect; +use crate::host_exit_code::{HostExitCode, KnownHostExitCode}; use crate::pdcstring::{PdCStr, PdCString}; #[cfg(windows)] @@ -109,6 +111,27 @@ pub type LoadAssemblyBytesFn = unsafe extern "system" fn( reserved: *const (), ) -> i32; +#[repr(C)] +struct GetHostfxrParameters { + size: libc::size_t, + assembly_path: *const char_t, + dotnet_root: *const char_t, +} + +#[derive(WrapperApi)] +struct NethostLib { + get_hostfxr_path: unsafe extern "C" fn( + buffer: *mut char_t, + buffer_size: *mut libc::size_t, + parameters: *const GetHostfxrParameters, + ) -> i32, +} + +struct NethostCandidate { + path: PathBuf, + dotnet_root: Option, +} + pub struct Hostfxr { pub lib: Container, } @@ -289,11 +312,417 @@ pub fn load_hostfxr() -> Result> { } pub fn load_hostfxr_from_pwsh_dir(pwsh_dir: impl AsRef) -> Result> { - Hostfxr::load_from_path(pwsh_dir.as_ref().join(if cfg!(target_os = "windows") { + let pwsh_dir = pwsh_dir.as_ref(); + let app_local_path = pwsh_dir.join(hostfxr_library_name()); + + match Hostfxr::load_from_path(app_local_path.as_os_str()) { + Ok(hostfxr) => Ok(hostfxr), + Err(app_local_error) => { + let fallback_path = resolve_hostfxr_path_from_global_install(pwsh_dir).map_err(|fallback_error| { + io::Error::new( + io::ErrorKind::NotFound, + format!( + "failed to load app-local hostfxr at {}: {}; global hostfxr fallback failed: {}", + app_local_path.display(), + app_local_error, + fallback_error + ), + ) + })?; + + Hostfxr::load_from_path(fallback_path.as_os_str()).map_err(|fallback_load_error| { + Box::new(io::Error::new( + io::ErrorKind::NotFound, + format!( + "failed to load app-local hostfxr at {}: {}; failed to load global hostfxr at {}: {}", + app_local_path.display(), + app_local_error, + fallback_path.display(), + fallback_load_error + ), + )) as Box + }) + } + } +} + +fn hostfxr_library_name() -> &'static str { + if cfg!(target_os = "windows") { "hostfxr.dll" } else if cfg!(target_os = "linux") { "libhostfxr.so" } else { "libhostfxr.dylib" - })) + } +} + +fn nethost_library_name() -> &'static str { + if cfg!(target_os = "windows") { + "nethost.dll" + } else if cfg!(target_os = "linux") { + "libnethost.so" + } else { + "libnethost.dylib" + } +} + +fn runtime_identifier() -> &'static str { + if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + "win-x64" + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + "win-arm64" + } else if cfg!(all(target_os = "windows", target_arch = "x86")) { + "win-x86" + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x64" + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + "linux-arm64" + } else if cfg!(all(target_os = "linux", target_arch = "arm")) { + "linux-arm" + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "osx-x64" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "osx-arm64" + } else { + "" + } +} + +fn resolve_hostfxr_path_from_global_install(pwsh_dir: &Path) -> Result> { + let mut errors = Vec::new(); + for candidate in nethost_candidates(pwsh_dir) { + match get_hostfxr_path_from_nethost(&candidate, pwsh_dir) { + Ok(path) => return Ok(path), + Err(error) => errors.push(format!("{}: {}", candidate.path.display(), error)), + } + } + + for path in global_hostfxr_paths() { + if path.is_file() { + return Ok(path); + } + } + + let detail = if errors.is_empty() { + "no nethost candidates were found".to_string() + } else { + errors.join("; ") + }; + Err(Box::new(io::Error::new( + io::ErrorKind::NotFound, + format!( + "failed to resolve hostfxr with nethost and standard .NET roots ({})", + detail + ), + ))) +} + +fn nethost_candidates(pwsh_dir: &Path) -> Vec { + let mut candidates = Vec::new(); + push_nethost_candidate(&mut candidates, pwsh_dir.join(nethost_library_name()), None); + + if let Ok(current_exe) = env::current_exe() { + if let Some(current_exe_dir) = current_exe.parent() { + push_nethost_candidate(&mut candidates, current_exe_dir.join(nethost_library_name()), None); + } + } + + for dotnet_root in dotnet_roots() { + for nethost_path in nethost_paths_in_dotnet_root(&dotnet_root) { + push_nethost_candidate(&mut candidates, nethost_path, Some(dotnet_root.clone())); + } + } + + candidates +} + +fn push_nethost_candidate(candidates: &mut Vec, path: PathBuf, dotnet_root: Option) { + if !path.is_file() { + return; + } + + if candidates + .iter() + .any(|candidate| paths_refer_to_same_file(&candidate.path, &path)) + { + return; + } + + candidates.push(NethostCandidate { path, dotnet_root }); +} + +fn nethost_paths_in_dotnet_root(dotnet_root: &Path) -> Vec { + let rid = runtime_identifier(); + if rid.is_empty() { + return Vec::new(); + } + + let host_pack_dir = dotnet_root + .join("packs") + .join(format!("Microsoft.NETCore.App.Host.{}", rid)); + let Ok(version_dirs) = std::fs::read_dir(host_pack_dir) else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + for entry in version_dirs.flatten() { + let version_name = entry.file_name(); + let path = entry + .path() + .join("runtimes") + .join(rid) + .join("native") + .join(nethost_library_name()); + if path.is_file() { + paths.push((version_key(&version_name), path)); + } + } + + paths.sort_by(|left, right| right.0.cmp(&left.0)); + paths.into_iter().map(|(_, path)| path).collect() +} + +fn global_hostfxr_paths() -> Vec { + let mut paths = Vec::new(); + for dotnet_root in dotnet_roots() { + paths.extend(hostfxr_paths_in_dotnet_root(&dotnet_root)); + } + paths +} + +fn hostfxr_paths_in_dotnet_root(dotnet_root: &Path) -> Vec { + let Ok(version_dirs) = std::fs::read_dir(dotnet_root.join("host").join("fxr")) else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + for entry in version_dirs.flatten() { + let version_name = entry.file_name(); + let path = entry.path().join(hostfxr_library_name()); + if path.is_file() { + paths.push((version_key(&version_name), path)); + } + } + + paths.sort_by(|left, right| right.0.cmp(&left.0)); + paths.into_iter().map(|(_, path)| path).collect() +} + +fn dotnet_roots() -> Vec { + let mut roots = Vec::new(); + + for name in dotnet_root_env_var_names() { + if let Some(value) = env::var_os(name) { + push_dotnet_root(&mut roots, PathBuf::from(value)); + } + } + + for path in default_dotnet_roots() { + push_dotnet_root(&mut roots, path); + } + + roots +} + +fn dotnet_root_env_var_names() -> Vec<&'static str> { + let mut names = Vec::new(); + if cfg!(target_arch = "x86_64") { + names.push("DOTNET_ROOT_X64"); + } else if cfg!(target_arch = "aarch64") { + names.push("DOTNET_ROOT_ARM64"); + } else if cfg!(target_arch = "x86") { + names.push("DOTNET_ROOT_X86"); + } + + names.push("DOTNET_ROOT"); + + if cfg!(all(target_os = "windows", target_arch = "x86")) { + names.push("DOTNET_ROOT(x86)"); + } + + names +} + +fn default_dotnet_roots() -> Vec { + if cfg!(target_os = "windows") { + let mut roots = Vec::new(); + if cfg!(target_arch = "x86") { + roots.push(PathBuf::from(r"C:\Program Files (x86)\dotnet")); + } + roots.push(PathBuf::from(r"C:\Program Files\dotnet")); + roots + } else if cfg!(target_os = "macos") { + vec![ + PathBuf::from("/usr/local/share/dotnet"), + PathBuf::from("/opt/homebrew/share/dotnet"), + ] + } else { + vec![ + PathBuf::from("/usr/share/dotnet"), + PathBuf::from("/usr/local/share/dotnet"), + ] + } +} + +fn push_dotnet_root(roots: &mut Vec, path: PathBuf) { + if !path.is_dir() { + return; + } + + if roots.iter().any(|root| paths_refer_to_same_file(root, &path)) { + return; + } + + roots.push(path); +} + +fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { + match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn version_key(name: &OsStr) -> Vec { + name.to_string_lossy() + .split(|ch: char| !ch.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .map(|part| part.parse::().unwrap_or(0)) + .collect() +} + +fn get_hostfxr_path_from_nethost( + candidate: &NethostCandidate, + pwsh_dir: &Path, +) -> Result> { + let nethost: Container = unsafe { Container::load(candidate.path.as_os_str())? }; + + let assembly_path = PdCString::from_os_str(pwsh_dir.join("pwsh.dll"))?; + let dotnet_root = candidate.dotnet_root.as_ref().map(PdCString::from_os_str).transpose()?; + let parameters = GetHostfxrParameters { + size: std::mem::size_of::(), + assembly_path: assembly_path.as_ptr(), + dotnet_root: dotnet_root.as_ref().map(|value| value.as_ptr()).unwrap_or(ptr::null()), + }; + + let mut buffer_size: libc::size_t = 260; + loop { + let previous_size = buffer_size as usize; + let mut buffer = vec![0 as char_t; buffer_size as usize]; + let result = unsafe { nethost.get_hostfxr_path(buffer.as_mut_ptr(), &mut buffer_size, ¶meters) }; + let exit_code = HostExitCode::from(result); + if exit_code.is_success() { + return Ok(path_from_char_buffer(&buffer)); + } + + if exit_code == HostExitCode::Known(KnownHostExitCode::HostApiBufferTooSmall) { + if buffer_size as usize <= previous_size { + buffer_size = (previous_size.saturating_mul(2).max(1)) as libc::size_t; + } + continue; + } + + return Err(Box::new(crate::error::Error::Hostfxr(exit_code))); + } +} + +#[cfg(windows)] +fn path_from_char_buffer(buffer: &[char_t]) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + + let len = buffer.iter().position(|value| *value == 0).unwrap_or(buffer.len()); + PathBuf::from(OsString::from_wide(&buffer[..len])) +} + +#[cfg(not(windows))] +fn path_from_char_buffer(buffer: &[char_t]) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let len = buffer.iter().position(|value| *value == 0).unwrap_or(buffer.len()); + let bytes = buffer[..len].iter().map(|value| *value as u8).collect(); + PathBuf::from(OsString::from_vec(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT_TEST_DIR_ID: AtomicUsize = AtomicUsize::new(0); + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let path = env::temp_dir().join(format!( + "pwsh-host-hostfxr-test-{}-{}", + std::process::id(), + NEXT_TEST_DIR_ID.fetch_add(1, Ordering::SeqCst) + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn version_key_compares_numeric_segments() { + assert!(version_key(OsStr::new("10.0.9")) > version_key(OsStr::new("8.0.28"))); + assert!(version_key(OsStr::new("8.0.28")) > version_key(OsStr::new("8.0.9"))); + } + + #[test] + fn nethost_paths_in_dotnet_root_returns_newest_first() { + let rid = runtime_identifier(); + if rid.is_empty() { + return; + } + + let temp_dir = TestDir::new(); + for version in ["8.0.28", "10.0.9"] { + let native_dir = temp_dir + .path() + .join("packs") + .join(format!("Microsoft.NETCore.App.Host.{}", rid)) + .join(version) + .join("runtimes") + .join(rid) + .join("native"); + fs::create_dir_all(&native_dir).unwrap(); + fs::write(native_dir.join(nethost_library_name()), "").unwrap(); + } + + let paths = nethost_paths_in_dotnet_root(temp_dir.path()); + assert_eq!(paths.len(), 2); + assert!(paths[0].display().to_string().contains("10.0.9")); + assert!(paths[1].display().to_string().contains("8.0.28")); + } + + #[test] + fn hostfxr_paths_in_dotnet_root_returns_newest_first() { + let temp_dir = TestDir::new(); + for version in ["8.0.28", "10.0.9"] { + let fxr_dir = temp_dir.path().join("host").join("fxr").join(version); + fs::create_dir_all(&fxr_dir).unwrap(); + fs::write(fxr_dir.join(hostfxr_library_name()), "").unwrap(); + } + + let paths = hostfxr_paths_in_dotnet_root(temp_dir.path()); + assert_eq!(paths.len(), 2); + assert!(paths[0].display().to_string().contains("10.0.9")); + assert!(paths[1].display().to_string().contains("8.0.28")); + } } diff --git a/crates/pwsh-host/src/lib.rs b/crates/pwsh-host/src/lib.rs index 717515b..5177407 100644 --- a/crates/pwsh-host/src/lib.rs +++ b/crates/pwsh-host/src/lib.rs @@ -28,7 +28,4 @@ pub use bindings::PowerShell; pub use loader::get_assembly_delegate_loader_for_pwsh_dir; pub use named_pipe_command::{preprocess_named_pipe_command_args, NamedPipeCommandError}; pub use pwsh_cli::{run_pwsh_command_line, run_pwsh_command_line_for_pwsh_dir, run_pwsh_command_line_for_pwsh_exe}; -pub use startup_hook::{ - MODULE_PATH_STRATEGY, STARTUP_HOOK_FORCE_MODULE_PATH_ENV_VAR, STARTUP_HOOK_MODULE_VENV_PATH_ENV_VAR, - STARTUP_HOOK_STRATEGY_ENV_VAR, -}; +pub use startup_hook::{MODULE_PATH_STRATEGY, STARTUP_HOOK_MODULE_VENV_PATH_ENV_VAR, STARTUP_HOOK_STRATEGY_ENV_VAR}; diff --git a/crates/pwsh-host/src/pwsh_cli.rs b/crates/pwsh-host/src/pwsh_cli.rs index 82f9e67..2be57e6 100644 --- a/crates/pwsh-host/src/pwsh_cli.rs +++ b/crates/pwsh-host/src/pwsh_cli.rs @@ -11,7 +11,6 @@ use crate::startup_hook::{STARTUP_HOOK_ASSEMBLY_NAME, STARTUP_HOOK_DLL}; const STARTUP_HOOKS_ENV_VAR: &str = "PWSH_HOST_STARTUP_HOOKS"; const MODULE_VENV_PATH_ENV_VAR: &str = "PSMODULE_VENV_PATH"; -const LEGACY_FORCE_MODULE_PATH_ENV_VAR: &str = "PWSH_STARTUP_HOOK_FORCE_PSMODULEPATH"; const LOG_PATH_ENV_VAR: &str = "PWSH_STARTUP_HOOK_LOG_PATH"; const STRATEGY_ENV_VAR: &str = "PWSH_STARTUP_HOOK_STRATEGY"; @@ -51,8 +50,7 @@ pub(crate) fn configure_startup_hooks_for_context( context: &HostfxrContext<'_, I>, ) -> Result<(), Box> { let startup_hooks = take_env_var(STARTUP_HOOKS_ENV_VAR); - let module_venv_path = - take_env_var(MODULE_VENV_PATH_ENV_VAR).or_else(|| take_env_var(LEGACY_FORCE_MODULE_PATH_ENV_VAR)); + let module_venv_path = take_env_var(MODULE_VENV_PATH_ENV_VAR); let log_path = take_env_var(LOG_PATH_ENV_VAR); let strategy = take_env_var(STRATEGY_ENV_VAR); let startup_hooks = resolve_startup_hooks( diff --git a/crates/pwsh-host/src/startup_hook.rs b/crates/pwsh-host/src/startup_hook.rs index ecba9d6..3cbde0a 100644 --- a/crates/pwsh-host/src/startup_hook.rs +++ b/crates/pwsh-host/src/startup_hook.rs @@ -1,5 +1,4 @@ pub const STARTUP_HOOK_MODULE_VENV_PATH_ENV_VAR: &str = "PSMODULE_VENV_PATH"; -pub const STARTUP_HOOK_FORCE_MODULE_PATH_ENV_VAR: &str = STARTUP_HOOK_MODULE_VENV_PATH_ENV_VAR; pub const STARTUP_HOOK_STRATEGY_ENV_VAR: &str = "PWSH_STARTUP_HOOK_STRATEGY"; pub const MODULE_PATH_STRATEGY: &str = "module-path"; pub(crate) const STARTUP_HOOK_ASSEMBLY_NAME: &str = "Devolutions.PowerShell.SDK.StartupHook"; diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index f364317..ac48fc0 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -8,15 +8,16 @@ This matrix reflects the current command surface and known gaps for `multi-pwsh` | --- | --- | --- | | Version info | `multi-pwsh --version`, `multi-pwsh -V`, `multi-pwsh version` | Prints the `multi-pwsh` package version without inspecting local install state. Extra arguments are rejected. | | Help | `multi-pwsh --help`, `multi-pwsh -h`, `multi-pwsh help [command]`, `multi-pwsh --help` | Focused command help is available without platform detection or local install state. | -| Install | `multi-pwsh install ` | `stable`, `preview`, and `lts` resolve against GitHub PowerShell releases. `major.minor.x` installs every available patch release in that line. | -| Update | `multi-pwsh update ` | Channel updates behave like installing the newest matching channel. Line updates refresh line, major, and managed named alias policies after installing the newest patch. | -| Uninstall | `multi-pwsh uninstall [--scope ] [--root ] [--force]` | Removes managed files and updates aliases that referenced the removed version. | -| List | `multi-pwsh list [--scope ] [--root ] [--available] [--include-prerelease]` | Installed listing shows paths, resolved aliases, named alias policies, and minor pins. Available listing queries GitHub releases. | +| Install | `multi-pwsh install ` | `stable`, `preview`, and `lts` resolve against GitHub PowerShell releases. `major.minor.x` installs every available patch release in that line. `--root` requires explicit `--scope `. | +| Update | `multi-pwsh update ` | Channel updates behave like installing the newest matching channel. Line updates refresh line, major, and managed named alias policies after installing the newest patch. `--root` requires explicit `--scope `. | +| Uninstall | `multi-pwsh uninstall [--scope ] [--root ] [--force]` | Removes managed files and updates aliases that referenced the removed version. User scope is the default; machine removals require explicit `--scope machine`. | +| List | `multi-pwsh list [--scope ] [--root ] [--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. | | 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 [pwsh arguments...]` | Runs through the native host. Alias shims can also invoke host mode implicitly when `pwsh-*` names are used from the managed bin directory. | +| Host | `multi-pwsh host [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. | | 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. | -| Package subcommand | `multi-pwsh package install/uninstall/list` | Lower-level scoped install backend retained for explicit package-style operations. | +| Package subcommand | `multi-pwsh package install/uninstall/list` | Advanced compatibility command for the scoped install backend; prefer top-level install, update, uninstall, and list. | +| AppHost NuGet package | `Devolutions.MultiPwsh.AppHost` | Inert-by-default package with opt-in MSBuild targets that copy a RID-specific `multi-pwsh` binary as `multi-pwsh` or `pwsh`/`pwsh.exe` for downstream SDK outputs. | ## Version selectors and channels @@ -74,6 +75,8 @@ 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. | +| Reusable AppHost NuGet package | Yes | `Devolutions.MultiPwsh.AppHost` packages RID-specific binaries and opt-in `buildTransitive` targets for downstream apphost replacement. | | Virtual environment module path | Yes | Host mode sets startup-hook environment variables and bootstraps module cmdlet aliases for `-Command` and stdin `-File -` scenarios. | | Venv archive import/export | Yes | ZIP import rejects absolute paths and parent-directory traversal. | diff --git a/docs/host-and-venv.md b/docs/host-and-venv.md index 29774bf..464b719 100644 --- a/docs/host-and-venv.md +++ b/docs/host-and-venv.md @@ -10,9 +10,39 @@ - On Windows, alias command paths are `pwsh-*.exe` host shims in the active install scope's `bin` directory. The default user scope uses `~/.pwsh/bin`, matching Linux/macOS. - 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. +- Direct `multi-pwsh host`, `alias`, `doctor`, and `venv` commands operate on the default user layout. Machine-scope aliases are normally entered through the generated machine-scope shims, which carry layout hints. - Copying or renaming `multi-pwsh.exe` to an alias-like name such as `pwsh-7.4.exe` also enters implicit host mode. - `-NamedPipeCommand ` is supported in host mode on Windows. +### Local `pwsh` apphost replacement mode + +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. + +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. + +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`. + +### AppHost NuGet package + +`Devolutions.MultiPwsh.AppHost` is the reusable package form of local apphost replacement mode. It ships RID-specific `multi-pwsh` binaries and `buildTransitive` targets, but has no build side effects unless `MultiPwshAppHostEnabled` is set to `true`. + +Typical downstream vendored-SDK usage: + +```xml + + + + + + true + pwsh + +``` + +The targets resolve the RID from `MultiPwshAppHostRuntimeIdentifier`, `PowerShellSDKAppHostRuntimeIdentifier`, `RuntimeIdentifier`, then `NETCoreSdkRuntimeIdentifier`. By default they copy `multi-pwsh` / `multi-pwsh.exe`; setting `MultiPwshAppHostOutputBaseName` to `pwsh` copies `pwsh` / `pwsh.exe`. Set `MultiPwshAppHostOutputName` for a full explicit file name. Downstream targets can also disable automatic copying with `MultiPwshAppHostCopyToOutput=false` and `MultiPwshAppHostCopyToPublish=false`, then consume `MultiPwshAppHostResolvedNativeBinary` or `@(MultiPwshAppHostNativeBinary)` directly. + ## Virtual environments `multi-pwsh` virtual environments provide isolated PowerShell module roots for hosted launches. @@ -70,17 +100,23 @@ Import is intentionally conservative: importing into an existing destination ven - Venv selection changes module discovery and import precedence for hosted launches. - `Install-Module` and `Install-PSResource` use the venv `Modules` directory during hosted launches. -- PowerShell may still include some built-in or default module paths in the effective `PSModulePath`; the venv is a selected module root, not a full process sandbox. -- The feature applies to `multi-pwsh host ...` and implicit host shims such as `pwsh-7.4.exe`, not to arbitrary external `pwsh` processes. +- The effective `PSModulePath` is the venv `Modules` directory plus the bundled PSHOME `Modules` directory when present; the venv is a selected module root, not a full process sandbox. +- The feature applies to `multi-pwsh host ...`, implicit host shims such as `pwsh-7.4.exe`, and local `pwsh` apphost replacement mode, not to arbitrary external `pwsh` processes. ## Managed paths -- `MULTI_PWSH_HOME`: override the multi-pwsh home directory. -- `MULTI_PWSH_BIN_DIR`: override the shim and launcher directory. -- `MULTI_PWSH_CACHE_DIR`: override the archive cache directory. -- `MULTI_PWSH_VENV_DIR`: override the virtual-environment root directory. +- `MULTI_PWSH_HOME`: override the default user-scope multi-pwsh home directory. +- `MULTI_PWSH_BIN_DIR`: override the default user-scope shim and launcher directory. +- `MULTI_PWSH_CACHE_DIR`: override the default user-scope archive/download cache directory. +- `MULTI_PWSH_VENV_DIR`: override the default user-scope virtual-environment root directory. - `MULTI_PWSH_CACHE_KEEP`: keep downloaded archives after extraction when set to a truthy value. +These `MULTI_PWSH_*` path variables affect only the default `user` layout. `machine` scope uses platform machine paths, and `--root` is an explicit install-root override that requires `--scope ` and does not mix in child-directory overrides from the environment. Empty or whitespace-only path values are treated as unset. + +Offline release bundles are separate from the archive/download cache. Use `MULTI_PWSH_OFFLINE_CACHE` or `--offline-cache ` to read releases from a warmed bundle. + +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. + CI cache example: ```powershell diff --git a/docs/testing.md b/docs/testing.md index fc680b9..905450f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -51,3 +51,14 @@ Useful flags: - `-ContinueOnFailure` keeps running after a failed alias. Pester must be available in the host PowerShell session. + +## AppHost NuGet package smoke tests + +Build the native NuGet packages first, then run the AppHost package smoke harness: + +```powershell +pwsh -NoLogo -NoProfile -File .\scripts\Build-NativeNuGetPackages.ps1 -RuntimeIdentifiers win-x64 -Packages AppHost -Clean +pwsh -NoLogo -NoProfile -NonInteractive -File .\tests\Invoke-AppHostNuGetPackageSmokeTest.ps1 -RuntimeIdentifier win-x64 +``` + +The smoke harness creates a temporary SDK-style sample project, restores `Devolutions.MultiPwsh.AppHost` from the local package source, validates build/publish output copying, and runs a renamed local apphost beside `pwsh.dll` and `pwsh.runtimeconfig.json` when a PowerShell payload is available. diff --git a/dotnet/startup-hook/StartupHook.cs b/dotnet/startup-hook/StartupHook.cs index ed997b8..709bc17 100644 --- a/dotnet/startup-hook/StartupHook.cs +++ b/dotnet/startup-hook/StartupHook.cs @@ -10,7 +10,6 @@ public static partial class StartupHook { private const string ModuleVenvPathProperty = "PSMODULE_VENV_PATH"; - private const string LegacyForceModulePathProperty = "PWSH_STARTUP_HOOK_FORCE_PSMODULEPATH"; private const string LogPathProperty = "PWSH_STARTUP_HOOK_LOG_PATH"; private const string StrategyProperty = "PWSH_STARTUP_HOOK_STRATEGY"; private const string ImportModuleCmdletHelperName = "Import-PWSHHostModule"; @@ -92,14 +91,12 @@ private static string GetEffectivePsModulePath() public static void Initialize() { - s_moduleVenvPath = ReadConfigurationValue(ModuleVenvPathProperty) - ?? ReadConfigurationValue(LegacyForceModulePathProperty); + s_moduleVenvPath = ReadConfigurationValue(ModuleVenvPathProperty); s_logPath = ReadConfigurationValue(LogPathProperty); s_strategy = ReadConfigurationValue(StrategyProperty); Environment.SetEnvironmentVariable("DOTNET_STARTUP_HOOKS", null); Environment.SetEnvironmentVariable("PSMODULE_VENV_PATH", null); - Environment.SetEnvironmentVariable("PWSH_STARTUP_HOOK_FORCE_PSMODULEPATH", null); Environment.SetEnvironmentVariable("PWSH_STARTUP_HOOK_LOG_PATH", null); Environment.SetEnvironmentVariable("PWSH_STARTUP_HOOK_STRATEGY", null); diff --git a/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.csproj b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.csproj new file mode 100644 index 0000000..bc91eac --- /dev/null +++ b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.csproj @@ -0,0 +1,47 @@ + + + 0.0.0 + Devolutions Inc. + Devolutions + Devolutions.MultiPwsh.AppHost + multi-pwsh;powershell;apphost;native;msbuild + Reusable multi-pwsh replacement apphost for vendored PowerShell SDK outputs. + netstandard2.0 + true + false + false + true + false + true + MIT + https://github.com/Devolutions/multi-pwsh + https://github.com/Devolutions/multi-pwsh.git + git + false + README.md + + + + + runtimes\win-x64\native\multi-pwsh.exe + + + runtimes\win-arm64\native\multi-pwsh.exe + + + runtimes\linux-x64\native + + + runtimes\linux-arm64\native + + + runtimes\osx-x64\native + + + runtimes\osx-arm64\native + + + + + + diff --git a/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.props b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.props new file mode 100644 index 0000000..fdddeaa --- /dev/null +++ b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.props @@ -0,0 +1,7 @@ + + + false + true + true + + diff --git a/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.targets b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.targets new file mode 100644 index 0000000..0de39a3 --- /dev/null +++ b/nuget/Devolutions.MultiPwsh.AppHost/Devolutions.MultiPwsh.AppHost.targets @@ -0,0 +1,88 @@ + + + + <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(MultiPwshAppHostRuntimeIdentifier)' != ''">$(MultiPwshAppHostRuntimeIdentifier) + <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == '' And '$(PowerShellSDKAppHostRuntimeIdentifier)' != ''">$(PowerShellSDKAppHostRuntimeIdentifier) + <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == '' And '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + <_MultiPwshAppHostResolvedRuntimeIdentifier Condition="'$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == '' And '$(NETCoreSdkRuntimeIdentifier)' != ''">$(NETCoreSdkRuntimeIdentifier) + + + + + + <_MultiPwshAppHostExeSuffix Condition="'$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == 'win-x64' Or '$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == 'win-arm64' Or '$(_MultiPwshAppHostResolvedRuntimeIdentifier)' == 'win-x86'">.exe + <_MultiPwshAppHostExeSuffix Condition="'$(_MultiPwshAppHostExeSuffix)' == ''"> + <_MultiPwshAppHostNativeFileName>multi-pwsh$(_MultiPwshAppHostExeSuffix) + <_MultiPwshAppHostOutputBaseName Condition="'$(MultiPwshAppHostOutputBaseName)' != ''">$(MultiPwshAppHostOutputBaseName) + <_MultiPwshAppHostOutputBaseName Condition="'$(_MultiPwshAppHostOutputBaseName)' == ''">multi-pwsh + <_MultiPwshAppHostOutputName Condition="'$(MultiPwshAppHostOutputName)' != ''">$(MultiPwshAppHostOutputName) + <_MultiPwshAppHostOutputName Condition="'$(_MultiPwshAppHostOutputName)' == ''">$(_MultiPwshAppHostOutputBaseName)$(_MultiPwshAppHostExeSuffix) + $(MSBuildThisFileDirectory)..\runtimes\$(_MultiPwshAppHostResolvedRuntimeIdentifier)\native\$(_MultiPwshAppHostNativeFileName) + + + + + + + $(_MultiPwshAppHostResolvedRuntimeIdentifier) + $(_MultiPwshAppHostOutputName) + + + + + + + <_MultiPwshAppHostBuildDestinationDirectory Condition="'$(MultiPwshAppHostDestinationDirectory)' != ''">$(MultiPwshAppHostDestinationDirectory) + <_MultiPwshAppHostBuildDestinationDirectory Condition="'$(_MultiPwshAppHostBuildDestinationDirectory)' == ''">$(OutDir) + + + + + + <_MultiPwshAppHostBuildOutputPath>$([System.IO.Path]::Combine('$(_MultiPwshAppHostBuildDestinationDirectory)', '$(_MultiPwshAppHostOutputName)')) + + + + + + + + + + + + + <_MultiPwshAppHostPublishDestinationDirectory Condition="'$(MultiPwshAppHostPublishDestinationDirectory)' != ''">$(MultiPwshAppHostPublishDestinationDirectory) + <_MultiPwshAppHostPublishDestinationDirectory Condition="'$(_MultiPwshAppHostPublishDestinationDirectory)' == ''">$(PublishDir) + + + + + + <_MultiPwshAppHostPublishOutputPath>$([System.IO.Path]::Combine('$(_MultiPwshAppHostPublishDestinationDirectory)', '$(_MultiPwshAppHostOutputName)')) + + + + + + + + + + diff --git a/nuget/Devolutions.MultiPwsh.AppHost/README.md b/nuget/Devolutions.MultiPwsh.AppHost/README.md new file mode 100644 index 0000000..443eabd --- /dev/null +++ b/nuget/Devolutions.MultiPwsh.AppHost/README.md @@ -0,0 +1,18 @@ +# Devolutions.MultiPwsh.AppHost + +`Devolutions.MultiPwsh.AppHost` ships RID-specific `multi-pwsh` native binaries and opt-in MSBuild targets for projects that need a reusable PowerShell replacement apphost. + +The package is inert by default. Set `MultiPwshAppHostEnabled=true` to copy the selected RID binary to build and publish outputs. + +```xml + + + + + + true + pwsh + +``` + +Downstream SDK packages can copy the binary as `pwsh` or `pwsh.exe` beside their own `pwsh.dll` and `pwsh.runtimeconfig.json`. In that layout, `multi-pwsh` runs the adjacent payload directly instead of resolving `pwsh` from PATH. diff --git a/scripts/Build-CliNativeNuGetPackage.ps1 b/scripts/Build-CliNativeNuGetPackage.ps1 index be57187..b968344 100644 --- a/scripts/Build-CliNativeNuGetPackage.ps1 +++ b/scripts/Build-CliNativeNuGetPackage.ps1 @@ -20,165 +20,15 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -if ([string]::IsNullOrWhiteSpace($StagingRoot)) { - $StagingRoot = Join-Path $repoRoot 'artifacts\cli\multi-pwsh' +$forwardedParameters = @{} +foreach ($key in $PSBoundParameters.Keys) { + $forwardedParameters[$key] = $PSBoundParameters[$key] } -elseif (-not [System.IO.Path]::IsPathRooted($StagingRoot)) { - $StagingRoot = Join-Path $repoRoot $StagingRoot -} - -if ([string]::IsNullOrWhiteSpace($OutputRoot)) { - $OutputRoot = Join-Path $repoRoot 'artifacts\cli-nuget' -} -elseif (-not [System.IO.Path]::IsPathRooted($OutputRoot)) { - $OutputRoot = Join-Path $repoRoot $OutputRoot -} - -function Invoke-NativeCommand { - param( - [Parameter(Mandatory)][string]$FilePath, - [string[]]$ArgumentList - ) - Write-Host ">> $FilePath $($ArgumentList -join ' ')" - & $FilePath @ArgumentList - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" - } +if (-not $forwardedParameters.ContainsKey('OutputRoot')) { + $forwardedParameters['OutputRoot'] = Join-Path $repoRoot 'artifacts\cli-nuget' } -function Get-SourceVersion { - $multiManifest = Join-Path $repoRoot 'crates\multi-pwsh\Cargo.toml' - $hostManifest = Join-Path $repoRoot 'crates\pwsh-host\Cargo.toml' - - $multiMatch = Select-String -Path $multiManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 - $hostMatch = Select-String -Path $hostManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 - - if (($null -eq $multiMatch) -or ($null -eq $hostMatch)) { - throw 'Unable to detect crate versions from Cargo.toml.' - } - - $multiVersion = $multiMatch.Matches[0].Groups[1].Value - $hostVersion = $hostMatch.Matches[0].Groups[1].Value - - if ($multiVersion -ne $hostVersion) { - throw "Crate version mismatch detected: multi-pwsh=$multiVersion pwsh-host=$hostVersion" - } - - $multiVersion -} - -function Resolve-RustTarget { - param([Parameter(Mandatory)][string]$RuntimeIdentifier) - - switch ($RuntimeIdentifier) { - 'win-x64' { @{ CargoTarget = 'x86_64-pc-windows-msvc'; BinaryName = 'multi-pwsh.exe' } } - 'win-arm64' { @{ CargoTarget = 'aarch64-pc-windows-msvc'; BinaryName = 'multi-pwsh.exe' } } - 'linux-x64' { @{ CargoTarget = 'x86_64-unknown-linux-gnu'; BinaryName = 'multi-pwsh' } } - 'linux-arm64' { @{ CargoTarget = 'aarch64-unknown-linux-gnu'; BinaryName = 'multi-pwsh' } } - 'osx-x64' { @{ CargoTarget = 'x86_64-apple-darwin'; BinaryName = 'multi-pwsh' } } - 'osx-arm64' { @{ CargoTarget = 'aarch64-apple-darwin'; BinaryName = 'multi-pwsh' } } - default { throw "Unsupported runtime identifier: $RuntimeIdentifier" } - } -} - -function Assert-FileExists { - param([Parameter(Mandatory)][string]$Path) - - if (-not (Test-Path -Path $Path -PathType Leaf)) { - throw "Expected file was not found: $Path" - } -} - -function Set-NupkgUnixExecutablePermissions { - param([Parameter(Mandatory)][string]$PackagePath) - - Add-Type -AssemblyName System.IO.Compression.FileSystem +$forwardedParameters['Packages'] = @('Cli') - $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) - try { - foreach ($entry in $archive.Entries) { - if ($entry.FullName -match '^runtimes/(linux|osx)-[^/]+/native/multi-pwsh$') { - $entry.ExternalAttributes = -2115174400 # 0o100755 << 16 as a signed Int32. - } - } - } - finally { - $archive.Dispose() - } -} - -if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = Get-SourceVersion -} - -if ([string]::IsNullOrWhiteSpace($Version)) { - throw 'Package version is empty.' -} - -if ($Clean) { - Remove-Item -Path $StagingRoot -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $OutputRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -New-Item -Path $StagingRoot -ItemType Directory -Force | Out-Null -New-Item -Path $OutputRoot -ItemType Directory -Force | Out-Null - -$cargoManifest = Join-Path $repoRoot 'crates\multi-pwsh\Cargo.toml' - -foreach ($rid in $RuntimeIdentifiers) { - $target = Resolve-RustTarget -RuntimeIdentifier $rid - $stageDir = Join-Path $StagingRoot $rid - New-Item -Path $stageDir -ItemType Directory -Force | Out-Null - - if (-not $NoBuild) { - $env:CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER = 'aarch64-linux-gnu-gcc' - $previousRustFlags = $env:RUSTFLAGS - try { - if ($target['CargoTarget'] -like '*-windows-msvc') { - $env:RUSTFLAGS = '-C target-feature=+crt-static' - } - - Invoke-NativeCommand -FilePath cargo -ArgumentList @( - 'build', - '--locked', - '--release', - '--package', - 'multi-pwsh', - '--bin', - 'multi-pwsh', - '--manifest-path', - $cargoManifest, - '--target', - $target['CargoTarget'] - ) - } - finally { - $env:RUSTFLAGS = $previousRustFlags - } - - $builtBinary = Join-Path $repoRoot "target\$($target['CargoTarget'])\release\$($target['BinaryName'])" - Assert-FileExists -Path $builtBinary - Copy-Item -Path $builtBinary -Destination (Join-Path $stageDir $target['BinaryName']) -Force - } - - Assert-FileExists -Path (Join-Path $stageDir $target['BinaryName']) -} - -if (-not $NoPack) { - $packageProject = Join-Path $repoRoot 'nuget\Devolutions.MultiPwsh.Cli\Devolutions.MultiPwsh.Cli.csproj' - Invoke-NativeCommand -FilePath dotnet -ArgumentList @( - 'pack', - $packageProject, - '-c', - $Configuration, - '-o', - $OutputRoot, - "/p:Version=$Version", - '/p:ContinuousIntegrationBuild=true' - ) - - $packagePath = Join-Path $OutputRoot "Devolutions.MultiPwsh.Cli.$Version.nupkg" - Assert-FileExists -Path $packagePath - Set-NupkgUnixExecutablePermissions -PackagePath $packagePath -} +& (Join-Path $PSScriptRoot 'Build-NativeNuGetPackages.ps1') @forwardedParameters diff --git a/scripts/Build-NativeNuGetPackages.ps1 b/scripts/Build-NativeNuGetPackages.ps1 new file mode 100644 index 0000000..00a9f82 --- /dev/null +++ b/scripts/Build-NativeNuGetPackages.ps1 @@ -0,0 +1,253 @@ +[CmdletBinding()] +param( + [string]$Version, + + [string]$Configuration = 'Release', + + [string]$StagingRoot, + + [string]$OutputRoot, + + [string[]]$RuntimeIdentifiers = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'), + + [ValidateSet('Cli', 'AppHost')] + [string[]]$Packages = @('Cli', 'AppHost'), + + [switch]$NoBuild, + + [switch]$NoPack, + + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if ([string]::IsNullOrWhiteSpace($StagingRoot)) { + $StagingRoot = Join-Path $repoRoot 'artifacts\cli\multi-pwsh' +} +elseif (-not [System.IO.Path]::IsPathRooted($StagingRoot)) { + $StagingRoot = Join-Path $repoRoot $StagingRoot +} + +if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + $OutputRoot = Join-Path $repoRoot 'artifacts\native-nuget' +} +elseif (-not [System.IO.Path]::IsPathRooted($OutputRoot)) { + $OutputRoot = Join-Path $repoRoot $OutputRoot +} + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$ArgumentList + ) + + Write-Host ">> $FilePath $($ArgumentList -join ' ')" + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-SourceVersion { + $multiManifest = Join-Path $repoRoot 'crates\multi-pwsh\Cargo.toml' + $hostManifest = Join-Path $repoRoot 'crates\pwsh-host\Cargo.toml' + + $multiMatch = Select-String -Path $multiManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 + $hostMatch = Select-String -Path $hostManifest -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 + + if (($null -eq $multiMatch) -or ($null -eq $hostMatch)) { + throw 'Unable to detect crate versions from Cargo.toml.' + } + + $multiVersion = $multiMatch.Matches[0].Groups[1].Value + $hostVersion = $hostMatch.Matches[0].Groups[1].Value + + if ($multiVersion -ne $hostVersion) { + throw "Crate version mismatch detected: multi-pwsh=$multiVersion pwsh-host=$hostVersion" + } + + $multiVersion +} + +function Resolve-RustTarget { + param([Parameter(Mandatory)][string]$RuntimeIdentifier) + + switch ($RuntimeIdentifier) { + 'win-x64' { @{ CargoTarget = 'x86_64-pc-windows-msvc'; BinaryName = 'multi-pwsh.exe' } } + 'win-arm64' { @{ CargoTarget = 'aarch64-pc-windows-msvc'; BinaryName = 'multi-pwsh.exe' } } + 'linux-x64' { @{ CargoTarget = 'x86_64-unknown-linux-gnu'; BinaryName = 'multi-pwsh' } } + 'linux-arm64' { @{ CargoTarget = 'aarch64-unknown-linux-gnu'; BinaryName = 'multi-pwsh' } } + 'osx-x64' { @{ CargoTarget = 'x86_64-apple-darwin'; BinaryName = 'multi-pwsh' } } + 'osx-arm64' { @{ CargoTarget = 'aarch64-apple-darwin'; BinaryName = 'multi-pwsh' } } + default { throw "Unsupported runtime identifier: $RuntimeIdentifier" } + } +} + +function Resolve-PackageProject { + param([Parameter(Mandatory)][string]$Package) + + switch ($Package) { + 'Cli' { + @{ + Id = 'Devolutions.MultiPwsh.Cli' + Project = Join-Path $repoRoot 'nuget\Devolutions.MultiPwsh.Cli\Devolutions.MultiPwsh.Cli.csproj' + FixedEntries = @('build/Devolutions.MultiPwsh.Cli.targets') + } + } + 'AppHost' { + @{ + Id = 'Devolutions.MultiPwsh.AppHost' + Project = Join-Path $repoRoot 'nuget\Devolutions.MultiPwsh.AppHost\Devolutions.MultiPwsh.AppHost.csproj' + FixedEntries = @( + 'buildTransitive/Devolutions.MultiPwsh.AppHost.props', + 'buildTransitive/Devolutions.MultiPwsh.AppHost.targets', + 'README.md' + ) + } + } + default { throw "Unsupported package: $Package" } + } +} + +function Assert-FileExists { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -Path $Path -PathType Leaf)) { + throw "Expected file was not found: $Path" + } +} + +function Set-NupkgUnixExecutablePermissions { + param([Parameter(Mandatory)][string]$PackagePath) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $archive.Entries) { + if ($entry.FullName -match '^runtimes/(linux|osx)-[^/]+/native/multi-pwsh$') { + $entry.ExternalAttributes = -2115174400 # 0o100755 << 16 as a signed Int32. + } + } + } + finally { + $archive.Dispose() + } +} + +function Assert-NupkgContents { + param( + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][hashtable]$PackageInfo, + [Parameter(Mandatory)][string[]]$ExpectedRuntimeIdentifiers + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $expectedEntries = New-Object System.Collections.Generic.List[string] + foreach ($rid in $ExpectedRuntimeIdentifiers) { + $target = Resolve-RustTarget -RuntimeIdentifier $rid + $expectedEntries.Add("runtimes/$rid/native/$($target['BinaryName'])") + } + + foreach ($entry in $PackageInfo['FixedEntries']) { + $expectedEntries.Add($entry) + } + + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $actualEntries = @{} + foreach ($entry in $archive.Entries) { + $actualEntries[$entry.FullName] = $true + } + + foreach ($entry in $expectedEntries) { + if (-not $actualEntries.ContainsKey($entry)) { + throw "Expected package entry '$entry' was not found in $PackagePath" + } + } + } + finally { + $archive.Dispose() + } +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = Get-SourceVersion +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + throw 'Package version is empty.' +} + +if ($Clean) { + Remove-Item -Path $StagingRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $OutputRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +New-Item -Path $StagingRoot -ItemType Directory -Force | Out-Null +New-Item -Path $OutputRoot -ItemType Directory -Force | Out-Null + +$cargoManifest = Join-Path $repoRoot 'crates\multi-pwsh\Cargo.toml' + +foreach ($rid in $RuntimeIdentifiers) { + $target = Resolve-RustTarget -RuntimeIdentifier $rid + $stageDir = Join-Path $StagingRoot $rid + New-Item -Path $stageDir -ItemType Directory -Force | Out-Null + + if (-not $NoBuild) { + $env:CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER = 'aarch64-linux-gnu-gcc' + $previousRustFlags = $env:RUSTFLAGS + try { + if ($target['CargoTarget'] -like '*-windows-msvc') { + $env:RUSTFLAGS = '-C target-feature=+crt-static' + } + + Invoke-NativeCommand -FilePath cargo -ArgumentList @( + 'build', + '--locked', + '--release', + '--package', + 'multi-pwsh', + '--bin', + 'multi-pwsh', + '--manifest-path', + $cargoManifest, + '--target', + $target['CargoTarget'] + ) + } + finally { + $env:RUSTFLAGS = $previousRustFlags + } + + $builtBinary = Join-Path $repoRoot "target\$($target['CargoTarget'])\release\$($target['BinaryName'])" + Assert-FileExists -Path $builtBinary + Copy-Item -Path $builtBinary -Destination (Join-Path $stageDir $target['BinaryName']) -Force + } + + Assert-FileExists -Path (Join-Path $stageDir $target['BinaryName']) +} + +if (-not $NoPack) { + foreach ($package in $Packages) { + $packageInfo = Resolve-PackageProject -Package $package + Invoke-NativeCommand -FilePath dotnet -ArgumentList @( + 'pack', + $packageInfo['Project'], + '-c', + $Configuration, + '-o', + $OutputRoot, + "/p:Version=$Version", + '/p:ContinuousIntegrationBuild=true' + ) + + $packagePath = Join-Path $OutputRoot "$($packageInfo['Id']).$Version.nupkg" + Assert-FileExists -Path $packagePath + Set-NupkgUnixExecutablePermissions -PackagePath $packagePath + Assert-NupkgContents -PackagePath $packagePath -PackageInfo $packageInfo -ExpectedRuntimeIdentifiers $RuntimeIdentifiers + } +} diff --git a/scripts/Bump-CrateVersions.ps1 b/scripts/Bump-CrateVersions.ps1 index d49e517..825e380 100644 --- a/scripts/Bump-CrateVersions.ps1 +++ b/scripts/Bump-CrateVersions.ps1 @@ -13,6 +13,11 @@ $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot $cratesRoot = Join-Path $repoRoot 'crates' $readmePath = Join-Path $repoRoot 'README.md' +$packageExamplePaths = @( + $readmePath, + (Join-Path $repoRoot 'docs\host-and-venv.md'), + (Join-Path $repoRoot 'nuget\Devolutions.MultiPwsh.AppHost\README.md') +) if (-not (Test-Path -Path $cratesRoot -PathType Container)) { throw "Crates directory not found: $cratesRoot" @@ -29,6 +34,7 @@ if (-not $cargoFiles) { $encoding = New-Object System.Text.UTF8Encoding($false) $updated = @() $readmeUpdated = $false +$packageExamplesUpdated = @() foreach ($cargoFile in $cargoFiles) { $content = [System.IO.File]::ReadAllText($cargoFile) @@ -80,6 +86,7 @@ if (Test-Path -Path $readmePath -PathType Leaf) { $newReadmeContent = $readmeContent $readmePatterns = @( '(?m)(?Install a specific tag \(example `v)(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?`\):)', + '(?m)(?releases/download/v)(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?/install-multi-pwsh\.(?:sh|ps1))', '(?m)(?bash -s -- v)(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)', '(?m)(?-Version v)(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)' ) @@ -106,7 +113,33 @@ if (Test-Path -Path $readmePath -PathType Leaf) { } } -if ($updated.Count -eq 0 -and -not $readmeUpdated) { +foreach ($packageExamplePath in $packageExamplePaths) { + if (-not (Test-Path -Path $packageExamplePath -PathType Leaf)) { + continue + } + + $content = [System.IO.File]::ReadAllText($packageExamplePath) + $newContent = [System.Text.RegularExpressions.Regex]::Replace( + $content, + '(?PackageReference Include="Devolutions\.MultiPwsh\.AppHost" Version=")(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?")', + [System.Text.RegularExpressions.MatchEvaluator] { + param($packageMatch) + $packageMatch.Groups['prefix'].Value + $Version + $packageMatch.Groups['suffix'].Value + }, + [System.Text.RegularExpressions.RegexOptions]::None, + [System.TimeSpan]::FromSeconds(5) + ) + + if ($newContent -ne $content) { + if (-not $DryRun) { + [System.IO.File]::WriteAllText($packageExamplePath, $newContent, $encoding) + } + + $packageExamplesUpdated += $packageExamplePath + } +} + +if ($updated.Count -eq 0 -and -not $readmeUpdated -and $packageExamplesUpdated.Count -eq 0) { Write-Host "All crate package versions are already $Version" if (Test-Path -Path $readmePath -PathType Leaf) { Write-Host "No README release example tag needed updating" @@ -125,3 +158,8 @@ if ($updated.Count -gt 0) { if ($readmeUpdated) { Write-Host "Updated README release example tag in: $readmePath" } + +if ($packageExamplesUpdated.Count -gt 0) { + Write-Host "Updated AppHost package reference versions in:" + $packageExamplesUpdated | ForEach-Object { Write-Host " - $_" } +} diff --git a/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 b/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 new file mode 100644 index 0000000..14a820e --- /dev/null +++ b/tests/Invoke-AppHostNuGetPackageSmokeTest.ps1 @@ -0,0 +1,247 @@ +[CmdletBinding()] +param( + [string]$PackageSource, + + [string]$PackageVersion, + + [string]$RuntimeIdentifier, + + [string]$Configuration = 'Debug', + + [string]$PowerShellPayloadPath = $env:PwshExePath, + + [switch]$SkipRuntimeSmoke, + + [switch]$KeepWorkspace +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if ([string]::IsNullOrWhiteSpace($PackageSource)) { + $PackageSource = Join-Path $repoRoot 'artifacts\native-nuget' +} +elseif (-not [System.IO.Path]::IsPathRooted($PackageSource)) { + $PackageSource = Join-Path $repoRoot $PackageSource +} + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$ArgumentList + ) + + Write-Host ">> $FilePath $($ArgumentList -join ' ')" + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-CurrentRuntimeIdentifier { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + $archName = switch ($arch) { + 'X64' { 'x64' } + 'Arm64' { 'arm64' } + 'X86' { 'x86' } + default { throw "Unsupported process architecture for AppHost smoke test: $arch" } + } + + if ($IsWindows) { + "win-$archName" + } + elseif ($IsMacOS) { + "osx-$archName" + } + else { + "linux-$archName" + } +} + +function Assert-FileExists { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -Path $Path -PathType Leaf)) { + throw "Expected file was not found: $Path" + } +} + +function Assert-UnixExecutable { + param([Parameter(Mandatory)][string]$Path) + + if ($IsWindows) { + return + } + + & /usr/bin/env sh -c 'test -x "$1"' sh $Path + if ($LASTEXITCODE -ne 0) { + throw "Expected file to be executable: $Path" + } +} + +function Resolve-PowerShellPayloadDirectory { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + $command = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $command) { + throw 'PowerShell payload path was not provided and pwsh could not be found.' + } + + $Path = $command.Source + } + + $resolved = (Resolve-Path $Path).Path + if (Test-Path -Path $resolved -PathType Leaf) { + Split-Path -Path $resolved -Parent + } + else { + $resolved + } +} + +if ([string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { + $RuntimeIdentifier = Get-CurrentRuntimeIdentifier +} + +$packageSource = (Resolve-Path $PackageSource).Path +$package = if ([string]::IsNullOrWhiteSpace($PackageVersion)) { + Get-ChildItem -Path $packageSource -Filter 'Devolutions.MultiPwsh.AppHost.*.nupkg' | + Sort-Object Name -Descending | + Select-Object -First 1 +} +else { + Get-ChildItem -Path $packageSource -Filter "Devolutions.MultiPwsh.AppHost.$PackageVersion.nupkg" | + Select-Object -First 1 +} + +if ($null -eq $package) { + throw "Devolutions.MultiPwsh.AppHost package was not found in $packageSource" +} + +if ([string]::IsNullOrWhiteSpace($PackageVersion)) { + $PackageVersion = $package.BaseName.Substring('Devolutions.MultiPwsh.AppHost.'.Length) +} + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "multi-pwsh-apphost-package-smoke-$([guid]::NewGuid().ToString('N'))" +$nugetCache = Join-Path $workspace 'nuget-cache' +$projectDir = Join-Path $workspace 'sample' +$env:NUGET_PACKAGES = $nugetCache + +try { + New-Item -Path $projectDir -ItemType Directory -Force | Out-Null + New-Item -Path $nugetCache -ItemType Directory -Force | Out-Null + + $outputName = if ($RuntimeIdentifier.StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'pwsh.exe' + } + else { + 'pwsh' + } + + $projectPath = Join-Path $projectDir 'AppHostPackageSmoke.csproj' + @" + + + net8.0 + $RuntimeIdentifier + false + true + $outputName + + + + + + + + + + + +"@ | Set-Content -Path $projectPath -Encoding utf8 + + $nugetConfig = Join-Path $projectDir 'NuGet.Config' + @" + + + + + + + + +"@ | Set-Content -Path $nugetConfig -Encoding utf8 + + $inertProjectDir = Join-Path $workspace 'inert-sample' + New-Item -Path $inertProjectDir -ItemType Directory -Force | Out-Null + $inertProjectPath = Join-Path $inertProjectDir 'AppHostPackageInertSmoke.csproj' + @" + + + net8.0 + $RuntimeIdentifier + false + + + + + + +"@ | Set-Content -Path $inertProjectPath -Encoding utf8 + + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('restore', $inertProjectPath, '--configfile', $nugetConfig) + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('build', $inertProjectPath, '--no-restore', '-c', $Configuration) + + $inertOutputDir = Join-Path $inertProjectDir "bin\$Configuration\net8.0\$RuntimeIdentifier" + foreach ($unexpectedName in @('multi-pwsh.exe', 'multi-pwsh', 'pwsh.exe', 'pwsh')) { + $unexpectedPath = Join-Path $inertOutputDir $unexpectedName + if (Test-Path -Path $unexpectedPath -PathType Leaf) { + throw "AppHost package should be inert by default, but found unexpected output: $unexpectedPath" + } + } + + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('restore', $projectPath, '--configfile', $nugetConfig) + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('build', $projectPath, '--no-restore', '-c', $Configuration) + Invoke-CheckedCommand -FilePath dotnet -ArgumentList @('publish', $projectPath, '--no-restore', '-c', $Configuration) + + $buildOutput = Join-Path $projectDir "bin\$Configuration\net8.0\$RuntimeIdentifier\$outputName" + $publishOutput = Join-Path $projectDir "bin\$Configuration\net8.0\$RuntimeIdentifier\publish\$outputName" + Assert-FileExists -Path $buildOutput + Assert-FileExists -Path $publishOutput + Assert-UnixExecutable -Path $buildOutput + Assert-UnixExecutable -Path $publishOutput + + if (-not $SkipRuntimeSmoke) { + $payloadDir = Resolve-PowerShellPayloadDirectory -Path $PowerShellPayloadPath + Assert-FileExists -Path (Join-Path $payloadDir 'pwsh.dll') + Assert-FileExists -Path (Join-Path $payloadDir 'pwsh.runtimeconfig.json') + + $payloadCopy = Join-Path $workspace 'payload' + Copy-Item -Path $payloadDir -Destination $payloadCopy -Recurse -Force + Copy-Item -Path $buildOutput -Destination (Join-Path $payloadCopy $outputName) -Force + Assert-UnixExecutable -Path (Join-Path $payloadCopy $outputName) + + Push-Location ([System.IO.Path]::GetTempPath()) + try { + Invoke-CheckedCommand -FilePath (Join-Path $payloadCopy $outputName) -ArgumentList @( + '-NoLogo', + '-NoProfile', + '-Command', + '$PSVersionTable.PSVersion.ToString()' + ) + } + finally { + Pop-Location + } + } +} +finally { + if ($KeepWorkspace) { + Write-Host "Kept smoke workspace: $workspace" + } + else { + Remove-Item -Path $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/tests/Invoke-ScopedInstallSmokeTest.ps1 b/tests/Invoke-ScopedInstallSmokeTest.ps1 index 41137bf..ecff1bc 100644 --- a/tests/Invoke-ScopedInstallSmokeTest.ps1 +++ b/tests/Invoke-ScopedInstallSmokeTest.ps1 @@ -35,6 +35,23 @@ function Assert-Contains { } } +function Assert-NotContains { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + + [Parameter(Mandatory = $true)] + [string]$Unexpected, + + [Parameter(Mandatory = $true)] + [string]$Context + ) + + if ($Text.Contains($Unexpected)) { + throw "Expected $Context not to contain '$Unexpected'.`nActual output:`n$Text" + } +} + function Invoke-MultiPwsh { param( [Parameter(Mandatory = $true)] @@ -242,10 +259,22 @@ foreach ($scope in @("user", "machine")) { } $ambiguousVersion = $resolvedVersions["user-7.4"] -$ambiguousUninstall = Invoke-MultiPwsh -Arguments @("uninstall", $ambiguousVersion) -AllowedExitCodes @(1) +$defaultUninstall = Invoke-MultiPwsh -Arguments @("uninstall", $ambiguousVersion) +Assert-Contains ` + -Text $defaultUninstall.Output ` + -Expected "Scope: user" ` + -Context "default uninstall output" + +$userListAfterDefaultUninstall = Invoke-MultiPwsh -Arguments @("list", "--scope", "user") +$machineListAfterDefaultUninstall = Invoke-MultiPwsh -Arguments @("list", "--scope", "machine") -UseMachinePrivileges:(-not $IsWindows) + +Assert-NotContains ` + -Text $userListAfterDefaultUninstall.Output ` + -Unexpected $ambiguousVersion ` + -Context "user scope list after default uninstall" Assert-Contains ` - -Text $ambiguousUninstall.Output ` - -Expected "installed in both user and machine scopes" ` - -Context "ambiguous uninstall output" + -Text $machineListAfterDefaultUninstall.Output ` + -Expected $ambiguousVersion ` + -Context "machine scope list after default uninstall" Write-Host "Scoped install smoke test completed successfully." diff --git a/tools/install-multi-pwsh.sh b/tools/install-multi-pwsh.sh index 7ff8986..418b3c6 100644 --- a/tools/install-multi-pwsh.sh +++ b/tools/install-multi-pwsh.sh @@ -51,8 +51,19 @@ while [[ $# -gt 0 ]]; do esac done -install_home="${MULTI_PWSH_HOME:-${HOME}/.pwsh}" -bin_dir="${MULTI_PWSH_BIN_DIR:-${install_home}/bin}" +path_env_or_default() { + local name="$1" + local default_value="$2" + local value="${!name:-}" + if [[ -n "${value//[[:space:]]/}" ]]; then + printf '%s' "${value}" + else + printf '%s' "${default_value}" + fi +} + +install_home="$(path_env_or_default MULTI_PWSH_HOME "${HOME}/.pwsh")" +bin_dir="$(path_env_or_default MULTI_PWSH_BIN_DIR "${install_home}/bin")" if [[ "${version}" == "latest" ]]; then release_path="latest/download" diff --git a/tools/uninstall-multi-pwsh.sh b/tools/uninstall-multi-pwsh.sh index 2e85185..f934e45 100644 --- a/tools/uninstall-multi-pwsh.sh +++ b/tools/uninstall-multi-pwsh.sh @@ -1,8 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -install_home="${MULTI_PWSH_HOME:-${HOME}/.pwsh}" -bin_dir="${MULTI_PWSH_BIN_DIR:-${install_home}/bin}" +path_env_or_default() { + local name="$1" + local default_value="$2" + local value="${!name:-}" + if [[ -n "${value//[[:space:]]/}" ]]; then + printf '%s' "${value}" + else + printf '%s' "${default_value}" + fi +} + +install_home="$(path_env_or_default MULTI_PWSH_HOME "${HOME}/.pwsh")" +bin_dir="$(path_env_or_default MULTI_PWSH_BIN_DIR "${install_home}/bin")" binary_path="${bin_dir}/multi-pwsh" remove_profile_entries() {