Skip to content

Commit 25ab5d9

Browse files
Add multi-pwsh virtual environment management (#24)
## Summary - add multi-pwsh virtual environment management commands for creating, listing, deleting, exporting, and importing named module environments - allow `multi-pwsh host` to consume `-VirtualEnvironment` and `-venv`, validate the selected environment, and temporarily set `PSModulePath` before handing control to `pwsh` - add `MULTI_PWSH_VENV_DIR`, expand CLI and integration coverage, refresh the README for the renamed `Devolutions/multi-pwsh` repository, and disable PowerShell update checks in host launches ## Verification - `cargo fmt --all --check` - `cargo clippy --workspace --all-targets` (existing `result_large_err` warnings only) - `cargo build --all-targets` - `cargo test --all-targets` - `dotnet build dotnet/Bindings.csproj` - `dotnet test dotnet/Bindings.csproj --no-build -v minimal`
1 parent af07742 commit 25ab5d9

6 files changed

Lines changed: 1173 additions & 47 deletions

File tree

README.md

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,42 @@
1-
# pwsh-host-rs
1+
# multi-pwsh
22

3-
Rust PowerShell hosting library that loads .NET delegates and drives `System.Management.Automation.PowerShell` through unmanaged entry points.
4-
5-
## multi-pwsh
6-
7-
Install and manage side-by-side PowerShell versions from GitHub Releases.
3+
Install and manage side-by-side PowerShell versions with aliases and native hosting.
84

95
![multi-pwsh](docs/images/multi-pwsh.png)
106

11-
### Bootstrap
7+
## Bootstrap
128

139
Latest release bootstrap scripts:
1410

1511
```bash
16-
curl -fsSL https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/install-multi-pwsh.sh | bash
12+
curl -fsSL https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/install-multi-pwsh.sh | bash
1713
```
1814

1915
```powershell
20-
irm https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/install-multi-pwsh.ps1 | iex
16+
irm https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/install-multi-pwsh.ps1 | iex
2117
```
2218

2319
Install a specific tag (example `v0.6.0`):
2420

2521
```bash
26-
curl -fsSL https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/install-multi-pwsh.sh | bash -s -- v0.6.0
22+
curl -fsSL https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/install-multi-pwsh.sh | bash -s -- v0.6.0
2723
```
2824

2925
```powershell
30-
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/install-multi-pwsh.ps1))) -Version v0.6.0
26+
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/install-multi-pwsh.ps1))) -Version v0.6.0
3127
```
3228

3329
Uninstall bootstrap scripts:
3430

3531
```bash
36-
curl -fsSL https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/uninstall-multi-pwsh.sh | bash
32+
curl -fsSL https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/uninstall-multi-pwsh.sh | bash
3733
```
3834

3935
```powershell
40-
irm https://raw.githubusercontent.com/Devolutions/pwsh-host-rs/refs/heads/master/tools/uninstall-multi-pwsh.ps1 | iex
36+
irm https://raw.githubusercontent.com/Devolutions/multi-pwsh/refs/heads/master/tools/uninstall-multi-pwsh.ps1 | iex
4137
```
4238

43-
### Install and verify aliases
39+
## Install and verify aliases
4440

4541
```powershell
4642
multi-pwsh install 7.4
@@ -55,7 +51,7 @@ pwsh-7.4 --version
5551
pwsh-7.5 --version
5652
```
5753

58-
### Manage installed lines
54+
## Manage installed lines
5955

6056
```powershell
6157
multi-pwsh install 7.4.x
@@ -71,7 +67,12 @@ multi-pwsh install 7.6.0-rc.1
7167
multi-pwsh update 7.6 --include-prerelease
7268
multi-pwsh alias set 7.4 7.4.11
7369
multi-pwsh alias unset 7.4
74-
multi-pwsh host 7.4 -NoLogo -NoProfile -Command "$PSVersionTable.PSVersion"
70+
multi-pwsh venv create msgraph
71+
multi-pwsh venv export msgraph msgraph.zip
72+
multi-pwsh venv import msgraph-copy msgraph.zip
73+
multi-pwsh venv delete msgraph
74+
multi-pwsh venv list
75+
multi-pwsh host 7.4 -venv msgraph -NoLogo -NoProfile -Command "$env:PSModulePath"
7576
multi-pwsh doctor --repair-aliases
7677
```
7778

@@ -82,9 +83,14 @@ multi-pwsh install <version|major|major.minor|major.minor.x> [--arch <auto|x64|x
8283
multi-pwsh update <major.minor> [--arch <auto|x64|x86|arm64|arm32>] [--include-prerelease]
8384
multi-pwsh uninstall <version> [--force]
8485
multi-pwsh list [--available] [--include-prerelease]
86+
multi-pwsh venv create <name>
87+
multi-pwsh venv delete <name>
88+
multi-pwsh venv export <name> <archive.zip>
89+
multi-pwsh venv import <name> <archive.zip>
90+
multi-pwsh venv list
8591
multi-pwsh alias set <major.minor> <version|latest>
8692
multi-pwsh alias unset <major.minor>
87-
multi-pwsh host <version|major|major.minor|pwsh-alias> [pwsh arguments...]
93+
multi-pwsh host <version|major|major.minor|pwsh-alias> [-VirtualEnvironment <name>|-venv <name>] [pwsh arguments...]
8894
multi-pwsh doctor --repair-aliases
8995
```
9096

@@ -103,18 +109,88 @@ Native host mode:
103109

104110
- `multi-pwsh host <selector> ...` runs PowerShell through native hosting (`pwsh-host` crate) instead of launching a `pwsh` subprocess.
105111
- `<selector>` supports `7`, `7.4`, `7.4.13`, or alias-form selectors such as `pwsh-7.4`.
112+
- `-VirtualEnvironment <name>` and `-venv <name>` are consumed by `multi-pwsh` before handing control to PowerShell and set `PSModulePath` to the selected venv root for that launch.
106113
- Alias lifecycle now maintains native host shims as hard links to `multi-pwsh` automatically during install/update/doctor alias repair.
107114
- On Windows, host shims are `pwsh-*.exe` files alongside `.cmd` wrappers in `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`).
108115
- On Linux/macOS, alias command paths (`pwsh-*`) are hard links to `multi-pwsh`.
109116
- `multi-pwsh doctor --repair-aliases` performs a shim health check and re-links broken hard links automatically.
110117
- You can still manually copy/rename `multi-pwsh.exe` under `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`) to an alias-like name (for example `pwsh-7.4.exe`); it automatically enters host mode and resolves the target installation from that alias name.
111118
- `-NamedPipeCommand <pipeName>` is supported in host mode (Windows only), matching `pwsh-host` behavior.
112119

120+
### Virtual environments
121+
122+
`multi-pwsh` virtual environments provide isolated PowerShell module roots. They are conceptually similar to Python virtual environments, but in this first version the isolation is implemented by selecting a venv-specific `PSModulePath` root for hosted launches.
123+
124+
By default, venvs live under `~/.pwsh/venv/<name>`. If `MULTI_PWSH_VENV_DIR` is set, they live under that directory instead.
125+
126+
Available commands:
127+
128+
- `multi-pwsh venv create <name>` creates a named venv.
129+
- `multi-pwsh venv delete <name>` removes a named venv.
130+
- `multi-pwsh venv export <name> <archive.zip>` exports a named venv to a zip archive.
131+
- `multi-pwsh venv import <name> <archive.zip>` imports a named venv from a zip archive.
132+
- `multi-pwsh venv list` shows the configured venv root and all known venvs.
133+
134+
#### Create and use a venv
135+
136+
Create a venv and launch a hosted PowerShell session that uses it:
137+
138+
```powershell
139+
multi-pwsh venv create msgraph
140+
multi-pwsh host 7.4 -venv msgraph -NoLogo -NoProfile
141+
```
142+
143+
You can verify which module root is being used:
144+
145+
```powershell
146+
multi-pwsh host 7.4 -venv msgraph -NoLogo -NoProfile -Command "$env:PSModulePath"
147+
```
148+
149+
Both `-venv <name>` and `-VirtualEnvironment <name>` are supported.
150+
151+
#### Populate a venv with modules
152+
153+
Venvs are module discovery roots, so modules should live directly under `<venv-root>/<ModuleName>`.
154+
155+
For the current implementation, the safest way to place modules into a venv is to save them directly into that venv root:
156+
157+
```powershell
158+
$venvRoot = Join-Path $HOME ".pwsh/venv/msgraph"
159+
Save-Module -Name Microsoft.Graph.Authentication -Repository PSGallery -Path $venvRoot -Force
160+
Save-Module -Name Microsoft.Graph.Users -Repository PSGallery -Path $venvRoot -Force
161+
```
162+
163+
Then use the venv when launching PowerShell:
164+
165+
```powershell
166+
multi-pwsh host 7.4 -venv msgraph -NoLogo -NoProfile -Command "Get-Module -ListAvailable Microsoft.Graph.Authentication"
167+
```
168+
169+
#### Export and import a venv
170+
171+
You can package a venv as a zip archive and recreate it elsewhere:
172+
173+
```powershell
174+
multi-pwsh venv export msgraph msgraph.zip
175+
multi-pwsh venv import msgraph-copy msgraph.zip
176+
multi-pwsh host 7.4 -venv msgraph-copy -NoLogo -NoProfile
177+
```
178+
179+
Import is intentionally conservative: importing into an existing destination venv is rejected instead of merging archive contents.
180+
181+
#### Current behavior and limitations
182+
183+
- Venv selection changes module discovery and import precedence for hosted launches.
184+
- In this first version, `Install-Module` is not automatically redirected into the venv just because `-venv` is used.
185+
- PowerShell may still include some built-in or default module paths in the effective `PSModulePath`; the venv is intended to be the selected module root, not a perfect process-level sandbox.
186+
- The venv feature currently applies to `multi-pwsh host ...` and implicit host shims such as `pwsh-7.4.exe`, not to arbitrary external `pwsh` processes.
187+
113188
Managed paths can be controlled with environment variables:
114189

115-
- `MULTI_PWSH_HOME`: override the multi-pwsh home directory (default: `~/.pwsh`). Extracted PowerShell versions are stored under `MULTI_PWSH_HOME/multi`, and alias metadata is stored in `MULTI_PWSH_HOME/aliases.json`.
190+
- `MULTI_PWSH_HOME`: override the multi-pwsh home directory (default: `~/.pwsh`). Extracted PowerShell versions are stored under `MULTI_PWSH_HOME/multi`, virtual environments are stored under `MULTI_PWSH_HOME/venv` unless `MULTI_PWSH_VENV_DIR` is set, and alias metadata is stored in `MULTI_PWSH_HOME/aliases.json`.
116191
- `MULTI_PWSH_BIN_DIR`: override the shim and launcher directory (default: `MULTI_PWSH_HOME/bin`).
117192
- `MULTI_PWSH_CACHE_DIR`: override archive cache directory (default: `MULTI_PWSH_HOME/cache`).
193+
- `MULTI_PWSH_VENV_DIR`: override the virtual-environment root directory (default: `MULTI_PWSH_HOME/venv`).
118194
- `MULTI_PWSH_CACHE_KEEP`: keep downloaded archives after extraction when set to a truthy value (`1`, `true`, `yes`, or `on`).
119195

120196
CI cache example:
@@ -123,6 +199,7 @@ CI cache example:
123199
$env:MULTI_PWSH_HOME = "$(Join-Path $HOME '.pwsh')"
124200
$env:MULTI_PWSH_BIN_DIR = "$(Join-Path $env:MULTI_PWSH_HOME 'bin')"
125201
$env:MULTI_PWSH_CACHE_DIR = "$(Join-Path $env:MULTI_PWSH_HOME 'cache')"
202+
$env:MULTI_PWSH_VENV_DIR = "$(Join-Path $env:MULTI_PWSH_HOME 'venv')"
126203
$env:MULTI_PWSH_CACHE_KEEP = "1"
127204
multi-pwsh install 7.4.x
128205
```
@@ -140,7 +217,7 @@ For background on this approach, see [dotnet/runtime#46652: Native Host using ex
140217

141218
Download the `pwsh-host-<os>-<arch>.zip` artifact for your platform from:
142219

143-
- https://github.com/Devolutions/pwsh-host-rs/releases
220+
- https://github.com/Devolutions/multi-pwsh/releases
144221

145222
Current artifact names:
146223

crates/multi-pwsh/src/layout.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct InstallLayout {
1111
home: PathBuf,
1212
bin_dir: PathBuf,
1313
cache_dir: PathBuf,
14+
venvs_dir: PathBuf,
1415
os: HostOs,
1516
}
1617

@@ -26,11 +27,15 @@ impl InstallLayout {
2627
let cache_dir = env::var_os("MULTI_PWSH_CACHE_DIR")
2728
.map(PathBuf::from)
2829
.unwrap_or_else(|| home.join("cache"));
30+
let venvs_dir = env::var_os("MULTI_PWSH_VENV_DIR")
31+
.map(PathBuf::from)
32+
.unwrap_or_else(|| home.join("venv"));
2933

3034
Ok(InstallLayout {
3135
home,
3236
bin_dir,
3337
cache_dir,
38+
venvs_dir,
3439
os,
3540
})
3641
}
@@ -51,6 +56,14 @@ impl InstallLayout {
5156
self.cache_dir.clone()
5257
}
5358

59+
pub fn venvs_dir(&self) -> PathBuf {
60+
self.venvs_dir.clone()
61+
}
62+
63+
pub fn venv_dir(&self, name: &str) -> PathBuf {
64+
self.venvs_dir().join(name)
65+
}
66+
5467
pub fn versions_dir(&self) -> PathBuf {
5568
self.home.join("multi")
5669
}
@@ -106,6 +119,7 @@ impl InstallLayout {
106119
fs::create_dir_all(&self.home)?;
107120
fs::create_dir_all(self.bin_dir())?;
108121
fs::create_dir_all(self.cache_dir())?;
122+
fs::create_dir_all(self.venvs_dir())?;
109123
fs::create_dir_all(self.versions_dir())?;
110124
Ok(())
111125
}
@@ -119,7 +133,7 @@ impl InstallLayout {
119133
collect_versions_from_dir(&self.versions_dir(), &[], self.executable_name(), &mut versions)?;
120134
collect_versions_from_dir(
121135
self.home(),
122-
&["bin", "cache", "multi"],
136+
&["bin", "cache", "multi", "venv"],
123137
self.executable_name(),
124138
&mut versions,
125139
)?;
@@ -197,13 +211,16 @@ mod tests {
197211
home: Option<&Path>,
198212
bin_dir: Option<&Path>,
199213
cache_dir: Option<&Path>,
214+
venv_dir: Option<&Path>,
200215
action: impl FnOnce() -> T,
201216
) -> T {
202217
let _guard = ENV_LOCK.lock().unwrap();
203218

204219
with_env_var("MULTI_PWSH_HOME", home, || {
205220
with_env_var("MULTI_PWSH_BIN_DIR", bin_dir, || {
206-
with_env_var("MULTI_PWSH_CACHE_DIR", cache_dir, action)
221+
with_env_var("MULTI_PWSH_CACHE_DIR", cache_dir, || {
222+
with_env_var("MULTI_PWSH_VENV_DIR", venv_dir, action)
223+
})
207224
})
208225
})
209226
}
@@ -213,11 +230,13 @@ mod tests {
213230
let temp_dir = TempDir::new().unwrap();
214231
let expected_home = temp_dir.path().join("pwsh-home");
215232

216-
with_layout_env(Some(&expected_home), None, None, || {
233+
with_layout_env(Some(&expected_home), None, None, None, || {
217234
let layout = InstallLayout::new(HostOs::Windows).unwrap();
218235
assert_eq!(layout.home(), expected_home.as_path());
219236
assert_eq!(layout.bin_dir(), expected_home.join("bin"));
220237
assert_eq!(layout.cache_dir(), expected_home.join("cache"));
238+
assert_eq!(layout.venvs_dir(), expected_home.join("venv"));
239+
assert_eq!(layout.venv_dir("msgraph"), expected_home.join("venv").join("msgraph"));
221240
assert_eq!(layout.versions_dir(), expected_home.join("multi"));
222241
assert_eq!(layout.aliases_file(), expected_home.join("aliases.json"));
223242
});
@@ -230,11 +249,33 @@ mod tests {
230249
let expected_bin = temp_dir.path().join("shims");
231250
let expected_cache = temp_dir.path().join("cache-root");
232251

233-
with_layout_env(Some(&expected_home), Some(&expected_bin), Some(&expected_cache), || {
252+
with_layout_env(
253+
Some(&expected_home),
254+
Some(&expected_bin),
255+
Some(&expected_cache),
256+
None,
257+
|| {
258+
let layout = InstallLayout::new(HostOs::Linux).unwrap();
259+
assert_eq!(layout.home(), expected_home.as_path());
260+
assert_eq!(layout.bin_dir(), expected_bin);
261+
assert_eq!(layout.cache_dir(), expected_cache);
262+
assert_eq!(layout.venvs_dir(), expected_home.join("venv"));
263+
assert_eq!(layout.versions_dir(), expected_home.join("multi"));
264+
},
265+
);
266+
}
267+
268+
#[test]
269+
fn layout_uses_explicit_venv_override() {
270+
let temp_dir = TempDir::new().unwrap();
271+
let expected_home = temp_dir.path().join("pwsh-home");
272+
let expected_venv = temp_dir.path().join("venvs-root");
273+
274+
with_layout_env(Some(&expected_home), None, None, Some(&expected_venv), || {
234275
let layout = InstallLayout::new(HostOs::Linux).unwrap();
235276
assert_eq!(layout.home(), expected_home.as_path());
236-
assert_eq!(layout.bin_dir(), expected_bin);
237-
assert_eq!(layout.cache_dir(), expected_cache);
277+
assert_eq!(layout.venvs_dir(), expected_venv);
278+
assert_eq!(layout.venv_dir("msgraph"), expected_venv.join("msgraph"));
238279
assert_eq!(layout.versions_dir(), expected_home.join("multi"));
239280
});
240281
}
@@ -247,7 +288,7 @@ mod tests {
247288
let legacy_dir = expected_home.join(version.to_string());
248289
fs::create_dir_all(&legacy_dir).unwrap();
249290

250-
with_layout_env(Some(&expected_home), None, None, || {
291+
with_layout_env(Some(&expected_home), None, None, None, || {
251292
let layout = InstallLayout::new(HostOs::Linux).unwrap();
252293
assert_eq!(layout.version_dir(&version), legacy_dir);
253294
assert_eq!(
@@ -271,7 +312,7 @@ mod tests {
271312
fs::write(new_dir.join("pwsh"), "").unwrap();
272313
fs::write(legacy_dir.join("pwsh"), "").unwrap();
273314

274-
with_layout_env(Some(&expected_home), None, None, || {
315+
with_layout_env(Some(&expected_home), None, None, None, || {
275316
let layout = InstallLayout::new(HostOs::Linux).unwrap();
276317
assert_eq!(layout.installed_versions().unwrap(), vec![new_version, legacy_version]);
277318
});

0 commit comments

Comments
 (0)