Skip to content

Commit a37a86f

Browse files
Add bundled pwsh sample
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 802f488 commit a37a86f

8 files changed

Lines changed: 468 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Native host mode:
152152
- On Linux/macOS, alias command paths (`pwsh-*`) are hard links to `multi-pwsh`.
153153
- `multi-pwsh doctor --repair-aliases` performs a shim health check and re-links broken hard links automatically.
154154
- 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.
155+
- A `.NET 10` bundled-host sample that publishes the packaged `pwsh.exe` from the `PowerShell` NuGet payload alongside `pwsh.dll` lives under `dotnet\samples\BundledPwshHost\README.md`.
155156
- `-NamedPipeCommand <pipeName>` is supported in host mode (Windows only), matching `pwsh-host` behavior.
156157

157158
### Virtual environments

crates/multi-pwsh/src/main.rs

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const POWERSHELL_UPDATECHECK_ENV_VAR: &str = "POWERSHELL_UPDATECHECK";
3333
const POWERSHELL_UPDATECHECK_OFF: &str = "Off";
3434
const VIRTUAL_ENVIRONMENT_FLAG: &str = "-virtualenvironment";
3535
const VIRTUAL_ENVIRONMENT_SHORT_FLAG: &str = "-venv";
36+
const BUNDLED_PWSH_HOME_ENV_VAR: &str = "MULTI_PWSH_BUNDLED_PWSH_HOME";
3637

3738
fn print_usage() {
3839
eprintln!(
@@ -525,6 +526,29 @@ fn run_host_mode(selector_input: &str, pwsh_args: Vec<OsString>) -> Result<i32>
525526
layout.ensure_base_dirs()?;
526527

527528
let (_version, executable) = resolve_host_executable(&layout, selector_input)?;
529+
let pwsh_dir = executable.parent().ok_or_else(|| {
530+
MultiPwshError::InvalidArguments(format!(
531+
"resolved host selector '{}' to {}, but executable has no parent directory",
532+
selector_input,
533+
executable.display()
534+
))
535+
})?;
536+
run_host_for_pwsh_dir(
537+
&layout,
538+
os,
539+
pwsh_dir,
540+
pwsh_args,
541+
format!("selector '{}'", selector_input),
542+
)
543+
}
544+
545+
fn run_host_for_pwsh_dir(
546+
layout: &InstallLayout,
547+
os: HostOs,
548+
pwsh_dir: &Path,
549+
pwsh_args: Vec<OsString>,
550+
failure_context: String,
551+
) -> Result<i32> {
528552
let HostLaunchOptions {
529553
pwsh_args,
530554
virtual_environment,
@@ -543,10 +567,10 @@ fn run_host_mode(selector_input: &str, pwsh_args: Vec<OsString>) -> Result<i32>
543567
.map(|venv_dir| configure_virtual_environment_host_env(os, &venv_dir))
544568
.transpose()?;
545569

546-
pwsh_host::run_pwsh_command_line_for_pwsh_exe(&executable, pwsh_args).map_err(|error| {
570+
pwsh_host::run_pwsh_command_line_for_pwsh_dir(pwsh_dir, pwsh_args).map_err(|error| {
547571
MultiPwshError::Host(format!(
548-
"failed to start native host for selector '{}': {}",
549-
selector_input, error
572+
"failed to start native host for {}: {}",
573+
failure_context, error
550574
))
551575
})
552576
}
@@ -597,8 +621,61 @@ fn detect_implicit_host_selector(bin_dir: &Path, executable_path: &Path) -> Opti
597621
Some(selector)
598622
}
599623

624+
fn resolve_bundled_pwsh_home(executable_path: &Path, configured_home: &Path) -> Option<PathBuf> {
625+
let candidate = if configured_home.is_absolute() {
626+
configured_home.to_path_buf()
627+
} else {
628+
executable_path.parent()?.join(configured_home)
629+
};
630+
631+
if is_bundled_pwsh_home(&candidate) {
632+
Some(candidate)
633+
} else {
634+
None
635+
}
636+
}
637+
638+
fn is_bundled_pwsh_home(path: &Path) -> bool {
639+
path.join("pwsh.dll").is_file()
640+
&& (path.join("pwsh.deps.json").is_file() || path.join("pwsh.runtimeconfig.json").is_file())
641+
}
642+
643+
fn detect_bundled_pwsh_home(executable_path: &Path) -> Option<PathBuf> {
644+
let selector_name = executable_selector_name(executable_path)?;
645+
if !selector_name.eq_ignore_ascii_case("pwsh") {
646+
return None;
647+
}
648+
649+
if let Some(configured_home) = env::var_os(BUNDLED_PWSH_HOME_ENV_VAR) {
650+
return resolve_bundled_pwsh_home(executable_path, Path::new(&configured_home));
651+
}
652+
653+
let parent = executable_path.parent()?;
654+
if is_bundled_pwsh_home(parent) {
655+
Some(parent.to_path_buf())
656+
} else {
657+
None
658+
}
659+
}
660+
600661
fn run_implicit_host_mode_if_needed() -> Result<Option<i32>> {
601662
let executable_path = env::current_exe()?;
663+
let args: Vec<OsString> = env::args_os().skip(1).collect();
664+
665+
if let Some(bundled_pwsh_home) = detect_bundled_pwsh_home(&executable_path) {
666+
let os = HostOs::detect()?;
667+
let layout = InstallLayout::new(os)?;
668+
layout.ensure_base_dirs()?;
669+
670+
let exit_code = run_host_for_pwsh_dir(
671+
&layout,
672+
os,
673+
&bundled_pwsh_home,
674+
args,
675+
format!("bundled pwsh home '{}'", bundled_pwsh_home.display()),
676+
)?;
677+
return Ok(Some(exit_code));
678+
}
602679

603680
let selector_name = match executable_selector_name(&executable_path) {
604681
Some(selector_name) => selector_name,
@@ -616,7 +693,6 @@ fn run_implicit_host_mode_if_needed() -> Result<Option<i32>> {
616693
return Ok(None);
617694
};
618695

619-
let args: Vec<OsString> = env::args_os().skip(1).collect();
620696
let exit_code = run_host_mode(&selector, args)?;
621697
Ok(Some(exit_code))
622698
}
@@ -1581,6 +1657,51 @@ mod tests {
15811657
assert!(selector.is_none());
15821658
}
15831659

1660+
#[test]
1661+
fn is_bundled_pwsh_home_requires_pwsh_dll_and_manifest() {
1662+
let temp_dir = tempfile::tempdir().unwrap();
1663+
fs::write(temp_dir.path().join("pwsh.dll"), []).unwrap();
1664+
assert!(!is_bundled_pwsh_home(temp_dir.path()));
1665+
1666+
fs::write(temp_dir.path().join("pwsh.runtimeconfig.json"), "{}").unwrap();
1667+
assert!(is_bundled_pwsh_home(temp_dir.path()));
1668+
}
1669+
1670+
#[test]
1671+
fn detect_bundled_pwsh_home_accepts_pwsh_name_with_sibling_payload() {
1672+
let temp_dir = tempfile::tempdir().unwrap();
1673+
fs::write(temp_dir.path().join("pwsh.dll"), []).unwrap();
1674+
fs::write(temp_dir.path().join("pwsh.deps.json"), "{}").unwrap();
1675+
1676+
let bundled_home = detect_bundled_pwsh_home(&temp_dir.path().join("pwsh.exe"));
1677+
assert_eq!(bundled_home, Some(temp_dir.path().to_path_buf()));
1678+
}
1679+
1680+
#[test]
1681+
fn detect_bundled_pwsh_home_rejects_non_pwsh_name() {
1682+
let temp_dir = tempfile::tempdir().unwrap();
1683+
fs::write(temp_dir.path().join("pwsh.dll"), []).unwrap();
1684+
fs::write(temp_dir.path().join("pwsh.deps.json"), "{}").unwrap();
1685+
1686+
let bundled_home = detect_bundled_pwsh_home(&temp_dir.path().join("multi-pwsh.exe"));
1687+
assert!(bundled_home.is_none());
1688+
}
1689+
1690+
#[test]
1691+
fn detect_bundled_pwsh_home_supports_relative_env_override() {
1692+
let temp_dir = tempfile::tempdir().unwrap();
1693+
let payload_dir = temp_dir.path().join("payload");
1694+
fs::create_dir_all(&payload_dir).unwrap();
1695+
fs::write(payload_dir.join("pwsh.dll"), []).unwrap();
1696+
fs::write(payload_dir.join("pwsh.runtimeconfig.json"), "{}").unwrap();
1697+
1698+
let bundled_home = with_env_var(BUNDLED_PWSH_HOME_ENV_VAR, Some("payload"), || {
1699+
detect_bundled_pwsh_home(&temp_dir.path().join("pwsh.exe"))
1700+
});
1701+
1702+
assert_eq!(bundled_home, Some(payload_dir));
1703+
}
1704+
15841705
#[test]
15851706
fn parse_release_selection_options_accepts_prerelease() {
15861707
let args = vec!["--include-prerelease".to_string()];
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFrameworks>net10.0;net10.0-windows</TargetFrameworks>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<SelfContained>true</SelfContained>
9+
<PublishSingleFile>false</PublishSingleFile>
10+
<PublishTrimmed>false</PublishTrimmed>
11+
<UseAppHost>true</UseAppHost>
12+
<BundledPwshSdkVersion>7.4.0</BundledPwshSdkVersion>
13+
<BundledPwshLayoutToolProject>$(MSBuildThisFileDirectory)tools\BundlePwshLayout\BundlePwshLayout.csproj</BundledPwshLayoutToolProject>
14+
<BundledPwshDotNetRoot Condition="'$(BundledPwshDotNetRoot)' == '' and '$(DOTNET_ROOT)' != ''">$(DOTNET_ROOT)</BundledPwshDotNetRoot>
15+
<BundledPwshDotNetRoot Condition="'$(BundledPwshDotNetRoot)' == '' and '$(OS)' == 'Windows_NT'">$(ProgramFiles)\dotnet</BundledPwshDotNetRoot>
16+
</PropertyGroup>
17+
18+
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-windows'">
19+
<UseWindowsForms>true</UseWindowsForms>
20+
</PropertyGroup>
21+
22+
<ItemGroup>
23+
<Compile Remove="tools\**\*.cs" />
24+
<PackageReference Include="Microsoft.PowerShell.SDK" Version="$(BundledPwshSdkVersion)" />
25+
<PackageReference Include="PowerShell"
26+
Version="$(BundledPwshSdkVersion)"
27+
GeneratePathProperty="true"
28+
ExcludeAssets="all"
29+
PrivateAssets="all" />
30+
</ItemGroup>
31+
32+
<PropertyGroup>
33+
<BundledPwshTargetPlatform Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('win'))">win</BundledPwshTargetPlatform>
34+
<BundledPwshTargetPlatform Condition="'$(BundledPwshTargetPlatform)' == '' and ($([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('linux')) or $([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('osx')))">unix</BundledPwshTargetPlatform>
35+
<BundledPwshPackageRoot Condition="'$(BundledPwshTargetPlatform)' != ''">$(PkgPowerShell)\tools\net8.0\any\$(BundledPwshTargetPlatform)</BundledPwshPackageRoot>
36+
</PropertyGroup>
37+
38+
<Target Name="ValidateBundledPwshPublish" BeforeTargets="Publish">
39+
<Error Condition="'$(RuntimeIdentifier)' == ''"
40+
Text="BundledPwshHost requires a RuntimeIdentifier. Example: dotnet publish -c Release -f net10.0-windows -r win-x64" />
41+
<Error Condition="'$(BundledPwshTargetPlatform)' == ''"
42+
Text="Unsupported RuntimeIdentifier '$(RuntimeIdentifier)'. Use a win-*, linux-*, or osx-* RID." />
43+
<Error Condition="'$(BundledPwshTargetPlatform)' == 'win' and '$(TargetFramework)' != 'net10.0-windows'"
44+
Text="Windows bundled publish requires -f net10.0-windows." />
45+
<Error Condition="'$(BundledPwshTargetPlatform)' != 'win' and '$(TargetFramework)' != 'net10.0'"
46+
Text="Non-Windows bundled publish requires -f net10.0." />
47+
<Error Condition="!Exists('$(BundledPwshPackageRoot)\pwsh.dll')"
48+
Text="PowerShell payload root was not found at $(BundledPwshPackageRoot)." />
49+
<Error Condition="!Exists('$(BundledPwshPackageRoot)\pwsh.exe') and '$(BundledPwshTargetPlatform)' == 'win'"
50+
Text="PowerShell package payload does not include pwsh.exe at $(BundledPwshPackageRoot)." />
51+
<Error Condition="!Exists('$(BundledPwshPackageRoot)\pwsh') and '$(BundledPwshTargetPlatform)' != 'win'"
52+
Text="PowerShell package payload does not include pwsh at $(BundledPwshPackageRoot)." />
53+
<Error Condition="!Exists('$(BundledPwshLayoutToolProject)')"
54+
Text="Bundled pwsh layout tool was not found at $(BundledPwshLayoutToolProject)." />
55+
<Error Condition="'$(BundledPwshDotNetRoot)' == ''"
56+
Text="Unable to resolve the local dotnet root. Set BundledPwshDotNetRoot or DOTNET_ROOT before publishing." />
57+
</Target>
58+
59+
<Target Name="BundlePowerShellPayload" AfterTargets="Publish">
60+
<ItemGroup>
61+
<BundledPwshPayload Include="$(BundledPwshPackageRoot)\**\*" />
62+
<BundledPwshMissingPayload Include="@(BundledPwshPayload)"
63+
Condition="!Exists('$(PublishDir)%(BundledPwshPayload.RecursiveDir)%(BundledPwshPayload.Filename)%(BundledPwshPayload.Extension)')" />
64+
</ItemGroup>
65+
66+
<Copy SourceFiles="@(BundledPwshMissingPayload)"
67+
DestinationFiles="@(BundledPwshMissingPayload -> '$(PublishDir)%(RecursiveDir)%(Filename)%(Extension)')"
68+
SkipUnchangedFiles="true" />
69+
</Target>
70+
71+
<Target Name="PrepareBundledPwshRuntime" AfterTargets="BundlePowerShellPayload">
72+
<PropertyGroup>
73+
<BundledPwshPublishDirNoSlash>$([System.String]::Copy('$(PublishDir)').TrimEnd('\'))</BundledPwshPublishDirNoSlash>
74+
</PropertyGroup>
75+
<Exec Command="dotnet run --project &quot;$(BundledPwshLayoutToolProject)&quot; -- &quot;$(BundledPwshPublishDirNoSlash)&quot; &quot;$(PublishDir)BundledPwshHost.runtimeconfig.json&quot; &quot;$(PublishDir)pwsh.runtimeconfig.json&quot; &quot;$(BundledPwshDotNetRoot)&quot;"
76+
WorkingDirectory="$(MSBuildThisFileDirectory)" />
77+
</Target>
78+
</Project>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System.Management.Automation;
2+
3+
namespace BundledPwshHost;
4+
5+
internal static class Program
6+
{
7+
private static int Main()
8+
{
9+
using PowerShell powerShell = PowerShell.Create();
10+
powerShell.AddScript("$PSVersionTable | Select-Object PSVersion, PSEdition | ConvertTo-Json -Compress");
11+
12+
var results = powerShell.Invoke();
13+
if (powerShell.HadErrors)
14+
{
15+
foreach (ErrorRecord error in powerShell.Streams.Error)
16+
{
17+
Console.Error.WriteLine(error);
18+
}
19+
20+
return 1;
21+
}
22+
23+
Console.WriteLine("Managed PowerShell SDK invocation:");
24+
foreach (PSObject result in results)
25+
{
26+
Console.WriteLine(result.BaseObject);
27+
}
28+
29+
Console.WriteLine($"Base directory: {AppContext.BaseDirectory}");
30+
Console.WriteLine("Publish this project with a RID to assemble a bundled pwsh payload.");
31+
return 0;
32+
}
33+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# BundledPwshHost
2+
3+
This sample shows how to:
4+
5+
- target `.NET 10`;
6+
- reference `Microsoft.PowerShell.SDK` from managed code;
7+
- publish a merged PowerShell-style output;
8+
- use the `pwsh.exe` shipped in the `PowerShell` NuGet package;
9+
- boot `pwsh.dll` from the publish directory without depending on a separately installed PowerShell.
10+
11+
## What the publish step does
12+
13+
`dotnet publish` for this sample:
14+
15+
1. publishes the sample app as a self-contained `.NET 10` app;
16+
2. overlays the `PowerShell` NuGet package payload (`pwsh.exe`, `pwsh.dll`, `pwsh.deps.json`, modules, and related files);
17+
4. rewrites `pwsh.runtimeconfig.json` to the local `.NET 10` shared framework version;
18+
5. copies the matching shared runtime folders from the local dotnet installation into `publish\shared\...`.
19+
20+
The bundled `pwsh.exe` is the official apphost that ships inside the `PowerShell` NuGet package payload. The sample keeps that executable and adjusts the runtime layout around it so it can start against the local `.NET 10` runtime copied into the publish output.
21+
22+
The package versions are intentionally linked: the `PowerShell` payload package reuses the same version property as `Microsoft.PowerShell.SDK` (`BundledPwshSdkVersion`), so changing the SDK version automatically changes the imported `pwsh.exe` / `pwsh.dll` payload version too.
23+
24+
## Prerequisites
25+
26+
Publish the sample from this directory so the local `global.json` selects `.NET 10`:
27+
28+
```powershell
29+
dotnet publish -c Release -f net10.0-windows -r win-x64
30+
```
31+
32+
## Validate the output
33+
34+
Run the managed sample app:
35+
36+
```powershell
37+
.\bin\Release\net10.0-windows\win-x64\publish\BundledPwshHost.exe
38+
```
39+
40+
Run the bundled PowerShell host:
41+
42+
```powershell
43+
.\bin\Release\net10.0-windows\win-x64\publish\pwsh.exe -NoLogo -NoProfile -Command '$PSHOME; [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription'
44+
```
45+
46+
On a successful run:
47+
48+
- `BundledPwshHost.exe` shows a PowerShell SDK invocation result;
49+
- `pwsh.exe` reports the sample publish directory as `$PSHOME`;
50+
- `pwsh.exe` reports the local `.NET 10` runtime version from the bundled layout.
51+
52+
## Current notes
53+
54+
- The payload selection logic is RID-aware (`win` / `unix`), but the validated path in this repository session is `win-x64`.
55+
- The PowerShell packages currently emit `NU1903` vulnerability warnings for transitive dependencies during publish. The sample does not suppress them.
56+
- This sample is a proof of concept for local hosting layout. It intentionally favors clarity and reproducibility over minimal output size.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"sdk": {
3+
"version": "10.0.103",
4+
"allowPrerelease": false,
5+
"rollForward": "latestPatch"
6+
}
7+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<Compile Include="Program.cs" />
12+
</ItemGroup>
13+
</Project>

0 commit comments

Comments
 (0)