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

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.20.11",
"version": "0.21.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,92 @@
/**
* Tests for shared/shell.ts platform-aware shell execution utilities.
*
* Uses platform parameter overrides for testability since process.platform is read-only.
*/
import { describe, expect, it } from "bun:test";
import { getInstallCmd, getInstallScriptUrl, getLocalShell, getWhichCommand, isWindows } from "../shared/shell";
const CDN = "https://example.com";
describe("isWindows", () => {
it("returns true for win32", () => {
expect(isWindows("win32")).toBe(true);
});
it("returns false for darwin", () => {
expect(isWindows("darwin")).toBe(false);
});
it("returns false for linux", () => {
expect(isWindows("linux")).toBe(false);
});
it("uses process.platform when no override", () => {
expect(isWindows()).toBe(process.platform === "win32");
});
});
describe("getLocalShell", () => {
it("returns powershell on Windows", () => {
const [shell, flag] = getLocalShell("win32");
expect(shell).toBe("powershell.exe");
expect(flag).toBe("-Command");
});
it("returns bash on macOS", () => {
const [shell, flag] = getLocalShell("darwin");
expect(shell).toBe("bash");
expect(flag).toBe("-c");
});
it("returns bash on Linux", () => {
const [shell, flag] = getLocalShell("linux");
expect(shell).toBe("bash");
expect(flag).toBe("-c");
});
});
describe("getInstallScriptUrl", () => {
it("returns .ps1 URL on Windows", () => {
expect(getInstallScriptUrl(CDN, "win32")).toBe(`${CDN}/cli/install.ps1`);
});
it("returns .sh URL on macOS", () => {
expect(getInstallScriptUrl(CDN, "darwin")).toBe(`${CDN}/cli/install.sh`);
});
it("returns .sh URL on Linux", () => {
expect(getInstallScriptUrl(CDN, "linux")).toBe(`${CDN}/cli/install.sh`);
});
});
describe("getInstallCmd", () => {
it("returns irm | iex on Windows", () => {
const cmd = getInstallCmd(CDN, "win32");
expect(cmd).toContain("irm");
expect(cmd).toContain("iex");
expect(cmd).toContain("install.ps1");
});
it("returns curl | bash on macOS", () => {
const cmd = getInstallCmd(CDN, "darwin");
expect(cmd).toContain("curl");
expect(cmd).toContain("bash");
expect(cmd).toContain("install.sh");
});
});
describe("getWhichCommand", () => {
it("returns 'where' on Windows", () => {
expect(getWhichCommand("win32")).toBe("where");
});
it("returns 'which' on macOS", () => {
expect(getWhichCommand("darwin")).toBe("which");
});
it("returns 'which' on Linux", () => {
expect(getWhichCommand("linux")).toBe("which");
});
});

View file

@ -10,6 +10,7 @@ import { generateSpawnId, getActiveServers, loadHistory, saveSpawnRecord } from
import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js";
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getLocalShell } from "../shared/shell.js";
import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
import { handleRecordAction } from "./list.js";
@ -482,13 +483,14 @@ function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void {
process.exit(130);
}
// ── Bash execution ───────────────────────────────────────────────────────────
// ── Script execution ─────────────────────────────────────────────────────────
function spawnBash(script: string, env: Record<string, string | undefined>): void {
function spawnScript(script: string, env: Record<string, string | undefined>): void {
const [shell, flag] = getLocalShell();
const result = spawnSync(
"bash",
shell,
[
"-c",
flag,
script,
],
{
@ -543,7 +545,7 @@ function runBash(script: string, prompt?: string, debug?: boolean, spawnName?: s
// gets a pristine file descriptor (prevents silent hangs / early exit)
prepareStdinForHandoff();
spawnBash(script, env);
spawnScript(script, env);
}
/**
@ -700,8 +702,8 @@ function headlessError(
process.exit(exitCode);
}
/** Run a bash script in headless mode (all output to stderr, no interactive session) */
function runBashHeadless(script: string, prompt?: string, debug?: boolean, spawnName?: string): Promise<number> {
/** Run a script in headless mode (all output to stderr, no interactive session) */
function runScriptHeadless(script: string, prompt?: string, debug?: boolean, spawnName?: string): Promise<number> {
validateScriptContent(script);
const env = {
@ -720,11 +722,12 @@ function runBashHeadless(script: string, prompt?: string, debug?: boolean, spawn
env.SPAWN_NAME_KEBAB = toKebabCase(spawnName);
}
const [shell, flag] = getLocalShell();
return new Promise<number>((resolve, reject) => {
const child = spawn(
"bash",
shell,
[
"-c",
flag,
script,
],
{
@ -885,7 +888,7 @@ export async function cmdRunHeadless(agent: string, cloud: string, opts: Headles
console.error(`[headless] Executing ${resolvedAgent} on ${resolvedCloud}...`);
}
const exitCode = await runBashHeadless(scriptContent, prompt, debug, spawnName);
const exitCode = await runScriptHeadless(scriptContent, prompt, debug, spawnName);
if (exitCode !== 0) {
headlessError(

View file

@ -1,13 +1,16 @@
import { execFileSync } from "node:child_process";
import { unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { RAW_BASE, SPAWN_CDN, VERSION_URL } from "../manifest.js";
import { parseJsonWith } from "../shared/parse.js";
import { asyncTryCatch, tryCatch } from "../shared/result.js";
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getInstallCmd, getInstallScriptUrl, isWindows } from "../shared/shell.js";
import { getErrorMessage, PkgVersionSchema, VERSION } from "./shared.js";
const INSTALL_URL = `${SPAWN_CDN}/cli/install.sh`;
const INSTALL_CMD = `curl --proto '=https' -fsSL ${INSTALL_URL} | bash`;
const INSTALL_URL = getInstallScriptUrl(SPAWN_CDN);
const INSTALL_CMD = getInstallCmd(SPAWN_CDN);
async function fetchRemoteVersion(): Promise<string> {
// Primary: plain-text version file from GitHub release artifact (static URL)
@ -41,9 +44,49 @@ async function fetchRemoteVersion(): Promise<string> {
return data.version;
}
function defaultRunUpdate(): void {
// Two-step: fetch with --proto '=https', then execute via bash -c
// Prevents protocol downgrade on hostile networks (matches update-check.ts pattern)
function runWindowsUpdate(): void {
const scriptContent = execFileSync(
"curl",
[
"--proto",
"=https",
"-fsSL",
INSTALL_URL,
],
{
encoding: "utf8",
stdio: [
"pipe",
"pipe",
"inherit",
],
},
);
// Write to temp file and execute via PowerShell (avoids string escaping issues)
const tmpFile = `${tmpdir()}\\spawn-install-${Date.now()}.ps1`;
writeFileSync(tmpFile, scriptContent ?? "");
const execResult = tryCatch(() =>
execFileSync(
"powershell.exe",
[
"-ExecutionPolicy",
"Bypass",
"-File",
tmpFile,
],
{
stdio: "inherit",
},
),
);
// Best-effort cleanup of temp file
tryCatchIf(isFileError, () => unlinkSync(tmpFile));
if (!execResult.ok) {
throw execResult.error;
}
}
function runUnixUpdate(): void {
const scriptContent = execFileSync(
"curl",
[
@ -73,6 +116,14 @@ function defaultRunUpdate(): void {
);
}
function defaultRunUpdate(): void {
if (isWindows()) {
runWindowsUpdate();
} else {
runUnixUpdate();
}
}
async function performUpdate(runUpdate: () => void = defaultRunUpdate): Promise<void> {
const r = tryCatch(() => runUpdate());
if (r.ok) {

View file

@ -3,16 +3,18 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getUserHome } from "../shared/paths";
import { getLocalShell } from "../shared/shell";
import { spawnInteractive } from "../shared/ssh";
// ─── Execution ───────────────────────────────────────────────────────────────
/** Run a shell command locally and wait for it to finish. */
export async function runLocal(cmd: string): Promise<void> {
const [shell, flag] = getLocalShell();
const proc = Bun.spawn(
[
"bash",
"-c",
shell,
flag,
cmd,
],
{
@ -54,9 +56,10 @@ export function downloadFile(remotePath: string, localPath: string): void {
/** Launch an interactive shell session locally. */
export async function interactiveSession(cmd: string): Promise<number> {
const [shell, flag] = getLocalShell();
return spawnInteractive([
"bash",
"-c",
shell,
flag,
cmd,
]);
}

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");

View file

@ -0,0 +1,71 @@
// shared/shell.ts — Platform-aware shell execution utilities
// Enables spawn CLI to work natively on Windows (PowerShell) without requiring bash.
/**
* Check if the current platform is Windows.
* Accepts an optional override for testability (process.platform is read-only).
*/
export function isWindows(platform?: string): boolean {
return (platform ?? process.platform) === "win32";
}
/**
* Get the local shell executable and its command flag for the current platform.
* - Windows: ["powershell.exe", "-Command"]
* - macOS/Linux: ["bash", "-c"]
*
* Accepts an optional platform override for testability.
*/
export function getLocalShell(platform?: string): [
string,
string,
] {
if (isWindows(platform)) {
return [
"powershell.exe",
"-Command",
];
}
return [
"bash",
"-c",
];
}
/**
* Get the install script URL for the current platform.
* - Windows: install.ps1
* - macOS/Linux: install.sh
*/
export function getInstallScriptUrl(cdnBase: string, platform?: string): string {
if (isWindows(platform)) {
return `${cdnBase}/cli/install.ps1`;
}
return `${cdnBase}/cli/install.sh`;
}
/**
* Get the command to display for manual update instructions.
* - Windows: PowerShell download + execute
* - macOS/Linux: curl | bash
*/
export function getInstallCmd(cdnBase: string, platform?: string): string {
if (isWindows(platform)) {
const url = `${cdnBase}/cli/install.ps1`;
return `irm ${url} | iex`;
}
const url = `${cdnBase}/cli/install.sh`;
return `curl --proto '=https' -fsSL ${url} | bash`;
}
/**
* Get the command name to locate executables on the current platform.
* - Windows: "where"
* - macOS/Linux: "which"
*/
export function getWhichCommand(platform?: string): string {
if (isWindows(platform)) {
return "where";
}
return "which";
}

View file

@ -3,6 +3,7 @@ import type { ExecFileSyncOptions } from "node:child_process";
import { execFileSync as nodeExecFileSync } from "node:child_process";
import fs from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { getErrorMessage, hasStatus } from "@openrouter/spawn-shared";
import pc from "picocolors";
@ -11,6 +12,7 @@ import { RAW_BASE, SPAWN_CDN, VERSION_URL } from "./manifest.js";
import { PkgVersionSchema, parseJsonWith } from "./shared/parse";
import { getUpdateFailedPath } from "./shared/paths";
import { asyncTryCatchIf, isFileError, isNetworkError, tryCatch, tryCatchIf, unwrapOr } from "./shared/result";
import { getInstallCmd, getInstallScriptUrl, getWhichCommand, isWindows } from "./shared/shell";
import { logDebug, logWarn } from "./shared/ui";
const VERSION = pkg.version;
@ -140,14 +142,17 @@ function printUpdateBanner(latestVersion: string): void {
/**
* Find the spawn binary to re-exec after an update.
*
* Prefers `which spawn` (PATH resolution) over process.argv[1] because the
* installer may place the new binary in a different directory than where the
* currently running binary lives, causing re-exec to run the stale old binary.
* Prefers PATH resolution over process.argv[1] because the installer may place
* the new binary in a different directory than where the currently running
* binary lives, causing re-exec to run the stale old binary.
*
* Uses `where` on Windows, `which` on macOS/Linux.
*/
function findUpdatedBinary(): string {
const whichCmd = getWhichCommand();
const r = tryCatch(() =>
executor.execFileSync(
"which",
whichCmd,
[
"spawn",
],
@ -161,7 +166,8 @@ function findUpdatedBinary(): string {
},
),
);
const found = r.ok && r.data ? r.data.toString().trim() : "";
// `where` on Windows may return multiple lines; take the first
const found = r.ok && r.data ? r.data.toString().trim().split("\n")[0].trim() : "";
if (found) {
return found;
}
@ -200,11 +206,11 @@ function reExecWithArgs(): void {
function performAutoUpdate(latestVersion: string): void {
printUpdateBanner(latestVersion);
// Hardcoded CDN URL — no variable interpolation, eliminates CWE-78 concern entirely
const installUrl = `${SPAWN_CDN}/cli/install.sh`;
const installUrl = getInstallScriptUrl(SPAWN_CDN);
const installCmd = getInstallCmd(SPAWN_CDN);
const updateResult = tryCatch(() => {
// Two-step approach: fetch script bytes with curl, then execute via bash -c
// Fetch script bytes with curl (available on all modern platforms)
const scriptBytes = executor.execFileSync(
"curl",
[
@ -223,16 +229,43 @@ function performAutoUpdate(latestVersion: string): void {
},
);
const scriptContent = scriptBytes ? scriptBytes.toString() : "";
executor.execFileSync(
"bash",
[
"-c",
scriptContent,
],
{
stdio: "inherit",
},
);
if (isWindows()) {
// Windows: write to temp file and execute via PowerShell
const tmpFile = path.join(tmpdir(), `spawn-install-${Date.now()}.ps1`);
fs.writeFileSync(tmpFile, scriptContent);
const psResult = tryCatch(() =>
executor.execFileSync(
"powershell.exe",
[
"-ExecutionPolicy",
"Bypass",
"-File",
tmpFile,
],
{
stdio: "inherit",
},
),
);
// Best-effort cleanup of temp file
tryCatchIf(isFileError, () => fs.unlinkSync(tmpFile));
if (!psResult.ok) {
throw psResult.error;
}
} else {
// macOS/Linux: execute via bash -c
executor.execFileSync(
"bash",
[
"-c",
scriptContent,
],
{
stdio: "inherit",
},
);
}
});
if (updateResult.ok) {
@ -246,7 +279,7 @@ function performAutoUpdate(latestVersion: string): void {
console.error(pc.red(pc.bold(`${CROSS_MARK} Auto-update failed`)));
console.error(pc.dim(" Please update manually:"));
console.error();
console.error(pc.cyan(` curl -fsSL ${installUrl} | bash`));
console.error(pc.cyan(` ${installCmd}`));
console.error();
// Continue with original command despite update failure
}