feat: add Windows PowerShell support — remove bash dependency for local execution (#2727)

Replace hardcoded "bash" shell references with platform-aware utilities so
spawn works natively from PowerShell on Windows without WSL or Git Bash.

- New shared/shell.ts: isWindows(), getLocalShell(), getInstallScriptUrl(),
  getInstallCmd(), getWhichCommand() with platform override for testability
- local/local.ts: use getLocalShell() for runLocal() and interactiveSession()
- commands/run.ts: spawnScript/runScriptHeadless use getLocalShell()
- commands/update.ts: Windows downloads install.ps1, runs via PowerShell
- update-check.ts: Windows auto-update uses install.ps1; "where" replaces "which"
- shared/orchestrate.ts: PowerShell-compatible .spawnrc setup for local Windows
- Remote SSH commands unchanged — remote servers are always Linux

Closes #2726

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-17 16:35:23 -07:00 committed by GitHub
parent ba94f681b3
commit 66b16d8651
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 54 deletions

View file

@ -16,6 +16,7 @@ import { generateEnvConfig } from "./agents";
import { getOrPromptApiKey } from "./oauth";
import { getSpawnPreferencesPath } from "./paths";
import { asyncTryCatch, asyncTryCatchIf, isFileError, isOperationalError, tryCatchIf } from "./result.js";
import { isWindows } from "./shell";
import { startSshTunnel } from "./ssh";
import { ensureSshKeys, getSshKeyOpts } from "./ssh-keys";
import {
@ -199,21 +200,19 @@ export async function runOrchestration(
// 9. Inject environment variables via .spawnrc
logStep("Setting up environment variables...");
const envB64 = Buffer.from(envContent).toString("base64");
// On Windows local execution, use PowerShell-compatible env setup.
// Remote servers (SSH) are always Linux, so bash commands are correct for all non-local clouds.
const isLocalWindows = cloud.cloudName === "local" && isWindows();
const envSetupCmd = isLocalWindows
? `$bytes = [Convert]::FromBase64String('${envB64}'); ` + `[IO.File]::WriteAllBytes("$HOME/.spawnrc", $bytes)`
: `printf '%s' '${envB64}' | base64 -d > ~/.spawnrc && chmod 600 ~/.spawnrc; ` +
"for _rc in ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc; do " +
`grep -q 'source ~/.spawnrc' "$_rc" 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> "$_rc"; ` +
"done";
const envResult = await asyncTryCatch(() =>
withRetry(
"env setup",
() =>
wrapSshCall(
cloud.runner.runServer(
`printf '%s' '${envB64}' | base64 -d > ~/.spawnrc && chmod 600 ~/.spawnrc; ` +
"for _rc in ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc; do " +
`grep -q 'source ~/.spawnrc' "$_rc" 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> "$_rc"; ` +
"done",
),
),
2,
5,
),
withRetry("env setup", () => wrapSshCall(cloud.runner.runServer(envSetupCmd)), 2, 5),
);
if (!envResult.ok) {
logWarn("Environment setup had errors");