Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Native host mode:

- `multi-pwsh host <selector> ...` runs PowerShell through native hosting (`pwsh-host` crate) instead of launching a `pwsh` subprocess.
- `<selector>` supports `7`, `7.4`, `7.4.13`, or alias-form selectors such as `pwsh-7.4`.
- `-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.
- `-VirtualEnvironment <name>` and `-venv <name>` are consumed by `multi-pwsh` before handing control to PowerShell and set `PSModulePath` to the selected venv module root for that launch.
- `PSMODULE_VENV_PATH` can also be used as an explicit path-based venv selector for hosted launches. If it is already set in the environment, `multi-pwsh host` treats it as an intentional venv opt-in.
- Alias lifecycle now maintains native host shims as hard links to `multi-pwsh` automatically during install/update/doctor alias repair.
- On Windows, alias command paths are `pwsh-*.exe` host shims in `MULTI_PWSH_BIN_DIR` (default: `~/.pwsh/bin`).
Expand All @@ -209,7 +209,7 @@ Native host mode:

### Virtual environments

`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.
`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` entry for hosted launches.

By default, venvs live under `~/.pwsh/venv/<name>`. If `MULTI_PWSH_VENV_DIR` is set, they live under that directory instead.

Expand Down Expand Up @@ -245,20 +245,21 @@ $env:PSMODULE_VENV_PATH = Join-Path $HOME ".pwsh/venv/msgraph"
multi-pwsh host 7.4 -NoLogo -NoProfile
```

`-venv <name>` and `-VirtualEnvironment <name>` accept a venv name and resolve it to a path before launch. `PSMODULE_VENV_PATH` is the lower-level path form of the same idea and is useful when a parent PowerShell session already knows which venv path should flow to child hosted sessions.
`-venv <name>` and `-VirtualEnvironment <name>` accept a venv name and resolve it to a base path before launch. `PSMODULE_VENV_PATH` is the lower-level path form of the same idea and is useful when a parent PowerShell session already knows which venv path should flow to child hosted sessions.

If both a venv flag and `PSMODULE_VENV_PATH` are present, the flag wins for that launch because `multi-pwsh` resolves the named venv and sets the effective path explicitly. If neither is present, no venv-specific startup-hook behavior is enabled.

#### Populate a venv with modules

Venvs are module discovery roots, so modules should live directly under `<venv-root>/<ModuleName>`.
Venvs are base directories, and the hosted PowerShell module root is `<venv-root>/Modules`. Modules should live under `<venv-root>/Modules/<ModuleName>`.

For the current implementation, the safest way to place modules into a venv is to save them directly into that venv root:
For the current implementation, the safest way to place modules into a venv is to save them into that `Modules` directory:

```powershell
$venvRoot = Join-Path $HOME ".pwsh/venv/msgraph"
Save-Module -Name Microsoft.Graph.Authentication -Repository PSGallery -Path $venvRoot -Force
Save-Module -Name Microsoft.Graph.Users -Repository PSGallery -Path $venvRoot -Force
$venvModules = Join-Path $venvRoot "Modules"
Save-Module -Name Microsoft.Graph.Authentication -Repository PSGallery -Path $venvModules -Force
Save-Module -Name Microsoft.Graph.Users -Repository PSGallery -Path $venvModules -Force
```

Then use the venv when launching PowerShell:
Expand All @@ -282,7 +283,7 @@ Import is intentionally conservative: importing into an existing destination ven
#### Current behavior and limitations

- Venv selection changes module discovery and import precedence for hosted launches.
- In this first version, `Install-Module` is not automatically redirected into the venv just because `-venv` is used.
- `Install-Module` and `Install-PSResource` use the venv's `Modules` directory during hosted launches.
- 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.
- 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.

Expand Down
1 change: 1 addition & 0 deletions crates/multi-pwsh/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ fn run_venv(args: &[String]) -> Result<()> {
let name = validate_venv_name(&args[1])?;
let venv_dir = layout.venv_dir(name);
fs::create_dir_all(&venv_dir)?;
fs::create_dir_all(venv_dir.join("Modules"))?;

println!("Virtual environment: {}", name);
println!("Path: {}", venv_dir.display());
Expand Down
64 changes: 43 additions & 21 deletions crates/multi-pwsh/tests/cli_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ fn link_directory(link_path: &Path, target_path: &Path) {
}

fn save_gallery_module(module_name: &str, destination: &Path) {
std::fs::create_dir_all(destination).expect("failed to create gallery module destination");

let script = format!(
"$ErrorActionPreference = 'Stop'; \
$ProgressPreference = 'SilentlyContinue'; \
Expand All @@ -147,6 +149,10 @@ fn save_gallery_module(module_name: &str, destination: &Path) {
);
}

fn venv_modules_dir(venv_root: &Path) -> PathBuf {
venv_root.join("Modules")
}

fn query_module_bases(home: &Path, selector: &str, venv: &str) -> Value {
let command = "$result = [ordered]@{ \
Yayaml = @(Get-Module -ListAvailable Yayaml | Select-Object -ExpandProperty ModuleBase); \
Expand Down Expand Up @@ -488,11 +494,17 @@ fn venv_create_and_list_use_multi_pwsh_home() {
);

let expected_venv = temp_dir.path().join("venv").join("msgraph");
let expected_modules = venv_modules_dir(&expected_venv);
assert!(
expected_venv.is_dir(),
"expected venv dir at {}",
expected_venv.display()
);
assert!(
expected_modules.is_dir(),
"expected venv modules dir at {}",
expected_modules.display()
);

let list_output = run_multi_pwsh(&["venv", "list"], temp_dir.path());
assert!(
Expand Down Expand Up @@ -565,7 +577,7 @@ fn venv_export_and_import_round_trip_module_contents() {
);

let original_venv = temp_dir.path().join("venv").join("roundtrip");
let module_dir = original_venv.join("RoundTripModule");
let module_dir = venv_modules_dir(&original_venv).join("RoundTripModule");
std::fs::create_dir_all(&module_dir).expect("failed to create test module dir");
std::fs::write(
module_dir.join("RoundTripModule.psm1"),
Expand Down Expand Up @@ -600,7 +612,9 @@ fn venv_export_and_import_round_trip_module_contents() {
);

let imported_venv = temp_dir.path().join("venv").join("roundtrip-copy");
let imported_data = imported_venv.join("RoundTripModule").join("data.txt");
let imported_data = venv_modules_dir(&imported_venv)
.join("RoundTripModule")
.join("data.txt");
assert!(
imported_data.is_file(),
"expected imported data at {}",
Expand All @@ -619,7 +633,7 @@ fn venv_export_and_import_round_trip_module_contents() {

let module_bases = query_single_module_bases(temp_dir.path(), &version, "roundtrip-copy", "RoundTripModule");
assert!(
output_contains_module_base_under(&module_bases, &imported_venv),
output_contains_module_base_under(&module_bases, &venv_modules_dir(&imported_venv)),
"expected imported module to be discoverable from imported venv, got {:?}",
module_bases
);
Expand Down Expand Up @@ -702,21 +716,23 @@ fn host_venv_isolates_psgallery_modules() {

let yaml_root = temp_dir.path().join("venv").join("yaml");
let toml_root = temp_dir.path().join("venv").join("toml");
let yaml_modules_root = venv_modules_dir(&yaml_root);
let toml_modules_root = venv_modules_dir(&toml_root);

save_gallery_module("Yayaml", &yaml_root);
save_gallery_module("PSToml", &toml_root);
save_gallery_module("Yayaml", &yaml_modules_root);
save_gallery_module("PSToml", &toml_modules_root);

let yaml_result = query_module_bases(temp_dir.path(), &version, "yaml");
let yaml_bases = json_strings(&yaml_result, "Yayaml");
let toml_bases_from_yaml = json_strings(&yaml_result, "PSToml");

assert!(
output_contains_module_base_under(&yaml_bases, &yaml_root),
output_contains_module_base_under(&yaml_bases, &yaml_modules_root),
"expected Yayaml to be discovered from yaml venv, got {:?}",
yaml_bases
);
assert!(
!output_contains_module_base_under(&toml_bases_from_yaml, &toml_root),
!output_contains_module_base_under(&toml_bases_from_yaml, &toml_modules_root),
"did not expect PSToml from toml venv to leak into yaml venv, got {:?}",
toml_bases_from_yaml
);
Expand All @@ -726,12 +742,12 @@ fn host_venv_isolates_psgallery_modules() {
let yaml_bases_from_toml = json_strings(&toml_result, "Yayaml");

assert!(
output_contains_module_base_under(&toml_bases, &toml_root),
output_contains_module_base_under(&toml_bases, &toml_modules_root),
"expected PSToml to be discovered from toml venv, got {:?}",
toml_bases
);
assert!(
!output_contains_module_base_under(&yaml_bases_from_toml, &yaml_root),
!output_contains_module_base_under(&yaml_bases_from_toml, &yaml_modules_root),
"did not expect Yayaml from yaml venv to leak into toml venv, got {:?}",
yaml_bases_from_toml
);
Expand All @@ -755,8 +771,10 @@ fn host_venv_rewrites_powershellget_current_user_module_path() {
);

let venv_root = temp_dir.path().join("venv").join("msgraph");
let venv_modules_root = venv_modules_dir(&venv_root);
let runtime_paths = query_venv_runtime_paths(temp_dir.path(), &version, "msgraph");
let expected = venv_root.to_string_lossy().to_string();
let expected_modules = venv_modules_root.to_string_lossy().to_string();
let module_path_entries = split_module_path_entries(
runtime_paths["EnvPSModulePath"]
.as_str()
Expand All @@ -766,10 +784,10 @@ fn host_venv_rewrites_powershellget_current_user_module_path() {
assert_eq!(
module_path_entries.len(),
2,
"expected venv PSModulePath to contain only the venv and bundled PSHOME modules, got {:?}",
"expected venv PSModulePath to contain only the venv Modules path and bundled PSHOME modules, got {:?}",
module_path_entries
);
assert_eq!(module_path_entries[0], venv_root);
assert_eq!(module_path_entries[0], venv_modules_root);
assert!(
module_path_entries[1].ends_with("Modules"),
"expected bundled PSHOME modules path, got {:?}",
Expand All @@ -778,11 +796,11 @@ fn host_venv_rewrites_powershellget_current_user_module_path() {
assert_eq!(runtime_paths["EnvModuleVenvPath"].as_str(), Some(expected.as_str()));
assert_eq!(
runtime_paths["PowerShellGetCurrentUserModules"].as_str(),
Some(expected.as_str())
Some(expected_modules.as_str())
);
assert_eq!(
runtime_paths["PowerShellGetPsGetPathCurrentUser"].as_str(),
Some(expected.as_str())
Some(expected_modules.as_str())
);
}

Expand Down Expand Up @@ -855,13 +873,14 @@ fn host_venv_import_module_powershellget_keeps_get_installed_module_venv_aware()
);

let venv_root = temp_dir.path().join("venv").join("yaml");
save_gallery_module("Yayaml", &venv_root);
let venv_modules_root = venv_modules_dir(&venv_root);
save_gallery_module("Yayaml", &venv_modules_root);

let installed_location =
query_installed_module_location_after_powershellget_import(temp_dir.path(), &version, "yaml", "Yayaml");

assert!(
output_contains_module_base_under(&[installed_location], &venv_root),
output_contains_module_base_under(&[installed_location], &venv_modules_root),
"expected explicit PowerShellGet import to preserve venv-installed module discovery"
);
}
Expand Down Expand Up @@ -933,7 +952,8 @@ fn host_venv_stdin_import_module_powershellget_keeps_get_installed_module_venv_a
);

let venv_root = temp_dir.path().join("venv").join("yaml-stdin");
save_gallery_module("Yayaml", &venv_root);
let venv_modules_root = venv_modules_dir(&venv_root);
save_gallery_module("Yayaml", &venv_modules_root);

let host_output = run_multi_pwsh_with_stdin(
&["host", &version, "-venv", "yaml-stdin", "-NoLogo", "-NoProfile", "-File", "-"],
Expand All @@ -949,7 +969,7 @@ fn host_venv_stdin_import_module_powershellget_keeps_get_installed_module_venv_a

let stdout = normalize_output(&host_output.stdout);
assert!(
output_contains_module_base_under(&[stdout], &venv_root),
output_contains_module_base_under(&[stdout], &venv_modules_root),
"expected stdin-driven PowerShellGet import to preserve venv-installed module discovery"
);
}
Expand All @@ -972,13 +992,14 @@ fn host_venv_import_module_psresourceget_keeps_get_installed_psresource_venv_awa
);

let venv_root = temp_dir.path().join("venv").join("yaml-psresource");
save_gallery_module("Yayaml", &venv_root);
let venv_modules_root = venv_modules_dir(&venv_root);
save_gallery_module("Yayaml", &venv_modules_root);

let installed_location =
query_installed_psresource_location_after_import(temp_dir.path(), &version, "yaml-psresource", "Yayaml");

assert!(
output_contains_module_base_under(&[installed_location], &venv_root),
output_contains_module_base_under(&[installed_location], &venv_modules_root),
"expected explicit PSResourceGet import to preserve venv-installed resource discovery"
);
}
Expand All @@ -1001,7 +1022,8 @@ fn host_venv_stdin_import_module_psresourceget_keeps_get_installed_psresource_ve
);

let venv_root = temp_dir.path().join("venv").join("yaml-psresource-stdin");
save_gallery_module("Yayaml", &venv_root);
let venv_modules_root = venv_modules_dir(&venv_root);
save_gallery_module("Yayaml", &venv_modules_root);

let host_output = run_multi_pwsh_with_stdin(
&[
Expand All @@ -1027,7 +1049,7 @@ fn host_venv_stdin_import_module_psresourceget_keeps_get_installed_psresource_ve

let stdout = normalize_output(&host_output.stdout);
assert!(
output_contains_module_base_under(&[stdout], &venv_root),
output_contains_module_base_under(&[stdout], &venv_modules_root),
"expected stdin-driven PSResourceGet import to preserve venv-installed resource discovery"
);
}
Expand Down
8 changes: 4 additions & 4 deletions dotnet/startup-hook/StartupHook.ModulePathOverride.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public static partial class StartupHook
[MethodImpl(MethodImplOptions.NoInlining)]
private static string GetModuleVenvPathReplacement()
{
return GetModuleVenvPath() ?? string.Empty;
return GetModuleVenvModulesPath() ?? string.Empty;
}

[MethodImpl(MethodImplOptions.NoInlining)]
Expand All @@ -22,14 +22,14 @@ private static string GetEffectiveModulePathReplacement()
[MethodImpl(MethodImplOptions.NoInlining)]
private static string GetConfigModulePathReplacement(object powerShellConfig, int scope)
{
return GetModuleVenvPath() ?? string.Empty;
return GetModuleVenvModulesPath() ?? string.Empty;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static IEnumerable<string> GetEnumeratedModulePathReplacement(bool includeSystemModulePath, object context)
{
var yieldedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? moduleVenvPath = GetModuleVenvPath();
string? moduleVenvPath = GetModuleVenvModulesPath();

if (!string.IsNullOrWhiteSpace(moduleVenvPath) && yieldedPaths.Add(moduleVenvPath))
{
Expand Down Expand Up @@ -99,4 +99,4 @@ private static void ConfigureModulePathOverride(Assembly sma, Type moduleIntrins
PatchMethod(getComposedModulePath, effectiveModulePathReplacement);
PatchMethod(getConfigModulePath, configReplacement);
}
}
}
10 changes: 7 additions & 3 deletions dotnet/startup-hook/StartupHook.PSResourceGet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static IEnumerable<PSObject> InvokeGetInstalledPSResource(
string? scope,
string? path)
{
string? moduleVenvPath = GetModuleVenvPath();
string? moduleVenvPath = GetModuleVenvModulesPath();
if (string.IsNullOrWhiteSpace(moduleVenvPath))
{
yield break;
Expand Down Expand Up @@ -71,7 +71,7 @@ public static IEnumerable<PSObject> CompleteInstallPSResourceForVenv(
bool authenticodeCheck,
string? temporaryPath)
{
string? moduleVenvPath = GetModuleVenvPath();
string? moduleVenvPath = GetModuleVenvModulesPath();
if (string.IsNullOrWhiteSpace(moduleVenvPath))
{
yield break;
Expand Down Expand Up @@ -371,6 +371,8 @@ private static void InvokeSavePsResourceByName(
string? temporaryPath,
string moduleVenvPath)
{
Directory.CreateDirectory(moduleVenvPath);

using PowerShell powerShell = PowerShell.Create(RunspaceMode.CurrentRunspace);
powerShell.AddCommand("Microsoft.PowerShell.PSResourceGet\\Save-PSResource");
powerShell.AddParameter("Name", name);
Expand Down Expand Up @@ -406,6 +408,8 @@ private static void InvokeSavePsResourceByInputObject(
string? temporaryPath,
string moduleVenvPath)
{
Directory.CreateDirectory(moduleVenvPath);

using PowerShell powerShell = PowerShell.Create(RunspaceMode.CurrentRunspace);
powerShell.AddCommand("Microsoft.PowerShell.PSResourceGet\\Save-PSResource");
powerShell.AddParameter("InputObject", inputObject);
Expand Down Expand Up @@ -484,4 +488,4 @@ private static bool CommandSupportsParameter(string commandName, string paramete

return commandInfo.Parameters.ContainsKey(parameterName);
}
}
}
Loading
Loading