From c369e089a7acbc85dcef890f4b35a7ffa948eadf Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 27 Jul 2026 22:17:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,tooling):=20Windows=20support=20?= =?UTF-8?q?=E2=80=94=20shell=20selection,=20install.ps1,=20win-x64=20relea?= =?UTF-8?q?se=20package,=20Windows=20CI=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- .gitattributes | 15 + .github/workflows/ci.yml | 53 +++ .github/workflows/release.yml | 55 ++- .prettierrc.json | 3 +- README.md | 6 +- README.zh.md | 6 +- install.ps1 | 220 ++++++++++++ package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/commands/config.ts | 7 + packages/cli/src/i18n.ts | 8 + packages/cli/test/config-model.test.ts | 5 +- packages/cli/test/config-vault.test.ts | 5 +- packages/cli/test/lang-config.test.ts | 11 +- packages/cli/test/update.test.ts | 90 ++--- packages/core/package.json | 2 +- .../src/environment/tools/command/index.ts | 2 + .../src/environment/tools/command/session.ts | 56 ++- .../src/environment/tools/command/shell.ts | 121 +++++++ packages/core/src/internal/session-support.ts | 4 + packages/core/src/state/agent-state.ts | 49 ++- packages/core/src/state/default-config.ts | 3 + packages/core/test/builtin-agents.test.ts | 1 + packages/core/test/engine.test.ts | 26 +- packages/core/test/environment.test.ts | 22 +- packages/core/test/exec-session.test.ts | 4 +- packages/core/test/file-tools.test.ts | 22 +- packages/core/test/shell-resolver.test.ts | 112 ++++++ packages/core/test/state.test.ts | 331 +++++++++++++----- packages/core/vitest.config.ts | 16 + packages/docs/content/installation.en.md | 35 +- packages/docs/content/installation.zh.md | 35 +- packages/docs/content/tools.en.md | 2 + packages/docs/content/tools.zh.md | 2 + packages/landing/public/install.ps1 | 33 ++ packages/landing/src/lib/links.ts | 6 + packages/landing/src/lib/strings-en.ts | 8 +- packages/landing/src/lib/strings.ts | 9 +- packages/landing/src/sections/hero.tsx | 30 +- packages/landing/src/sections/quickstart.tsx | 14 +- packages/server/package.json | 2 +- .../src/services/workspace-files-service.ts | 9 + packages/server/test/models.test.ts | 5 +- packages/web/e2e/README.md | 5 + scripts/dev-prebuild.mjs | 10 +- scripts/run-with-env.mjs | 68 ++++ 46 files changed, 1315 insertions(+), 217 deletions(-) create mode 100644 .gitattributes create mode 100644 install.ps1 create mode 100644 packages/core/src/environment/tools/command/shell.ts create mode 100644 packages/core/test/shell-resolver.test.ts create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/landing/public/install.ps1 create mode 100644 scripts/run-with-env.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7a1de18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Normalize text files to LF in the repo AND in the working tree (eol=lf beats core.autocrlf), +# so a Windows checkout matches what Prettier's endOfLine=lf expects and shell scripts stay +# runnable under Git-Bash. +* text=auto eol=lf + +# Shell scripts must be LF (a CRLF shebang line breaks the interpreter). +*.sh text eol=lf + +# PowerShell is the one Windows-native text format we ship; keep it CRLF for notepad/PS 5.1 +# friendliness (PowerShell itself accepts both). +*.ps1 text eol=crlf + +# Binary assets: never subject to text normalization or diffs. +*.png binary +*.webp binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf90052..3d7e33b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ # CI: build -> style (Prettier) -> typecheck (tsc) -> unit tests (vitest) -> live e2e (DeepSeek). # Build first: core's exports point at dist/, and cli's type resolution and runtime imports both need core's build output. # e2e needs the repo secret DEEPSEEK_API_KEY; when absent (e.g. forks) that step self-skips and the other checks run as usual. +# A second job repeats build/typecheck/test on windows-latest (see ci-windows below). name: CI # Limit triggers to avoid duplicate runs: push runs only on main/dev; PRs always run once (no target-branch filter -- @@ -55,3 +56,55 @@ jobs: exit 0 fi pnpm test:e2e + + # Windows: the same build/typecheck/test gates on windows-latest (the ubuntu job above stays the + # required one). The runner ships Git-Bash on PATH, so core's exec_command tests run through the + # shell resolver's bash pick; genuinely POSIX-only tests skip themselves via process.platform + # guards (not CI filters), so local Windows devs see the same behavior. Line endings come from + # .gitattributes (LF working tree), keeping build outputs and fixtures byte-identical. + # No format:check (same files as ubuntu) and no live-LLM e2e here (ubuntu-only budget). + ci-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + # pnpm version comes from package.json's packageManager field. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build (tsup) + run: pnpm build + + - name: Typecheck (tsc) + run: pnpm typecheck + + - name: Unit tests (vitest) + run: pnpm test + + # The Linux job cannot validate PowerShell syntax; parse (never execute) the installer + # scripts with the real PowerShell parser so a broken install.ps1 cannot ship. + - name: Parse installer scripts (PowerShell) + shell: pwsh + run: | + $failed = $false + foreach ($f in @("install.ps1", "packages/landing/public/install.ps1")) { + $tokens = $null + $errors = $null + # .Path: hand ParseFile a plain string, not a PathInfo object. + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $failed = $true + Write-Host "${f}: $($errors.Count) parse error(s)" + $errors | ForEach-Object { Write-Host " $($_.Extent.StartLineNumber): $($_.Message)" } + } else { + Write-Host "${f}: OK" + } + } + if ($failed) { exit 1 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a418bd2..7af6e01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ # already-released tag skips the build/upload entirely and only re-runs npm publishing. # Two parallel jobs (the release job is gated on the existence check): # - release: build the monorepo -> pnpm deploy a production CLI dir -> assemble penguin/ (bin + lib + web) -# -> four platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release. -# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-universal.tar.gz, -# their .sha256 files, SHA256SUMS, and install.sh; one version per tag, multiple versions coexist. +# -> five platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release. +# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-win32-x64.zip, penguin-universal.tar.gz, +# their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple versions coexist. # - publish-npm: publish the whole chain (@prismshadow/penguin-skills -> @prismshadow/penguin-core # -> @prismshadow/penguin-server -> @prismshadow/penguin-cli) to npm at the tag version. # skills/core serve the penguin-sdk Skill's `npm install`; server ships the built web assets inside @@ -150,12 +150,53 @@ jobs: rm -rf out/penguin/node tar -czf dist-artifacts/penguin-universal.tar.gz -C out penguin - # SHA256SUMS summary + a same-named .sha256 per artifact (install.sh verifies against the latter). + # Windows package: same lib/ + web/ layout, but a .zip (the native format), the official + # win-x64 Node runtime — whose zip has node.exe at the ARCHIVE ROOT, not bin/ — and + # cmd/ps1 launchers replacing the sh one (resolve their own dir, default PENGUIN_WEB_DIST + # to the sibling web\, prefer the bundled node\node.exe, fall back to system node). + # install.ps1 verifies and unpacks this zip; in PowerShell the .ps1 shim wins over .cmd, + # in cmd.exe only the .cmd is found — both forward all args and the exit code. + - name: Package win-x64 zip + run: | + name="node-$NODE_RUNTIME_VERSION-win-x64" + curl -fsSL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$name.zip" -o "/tmp/$name.zip" + rm -rf /tmp/node-runtime out/penguin/node + mkdir -p /tmp/node-runtime + unzip -q "/tmp/$name.zip" -d /tmp/node-runtime + mv "/tmp/node-runtime/$name" out/penguin/node + rm -f out/penguin/bin/penguin + cat > out/penguin/bin/penguin.cmd <<'EOF' + @echo off + setlocal + set "DIR=%~dp0.." + if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web" + if exist "%DIR%\node\node.exe" ( + "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %* + ) else ( + node "%DIR%\lib\dist\index.js" %* + ) + exit /b %ERRORLEVEL% + EOF + # cmd.exe is only fully reliable with CRLF batch files. + sed -i 's/$/\r/' out/penguin/bin/penguin.cmd + cat > out/penguin/bin/penguin.ps1 <<'EOF' + $dir = Split-Path -Parent $PSScriptRoot + if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" } + $node = Join-Path $dir "node\node.exe" + if (-not (Test-Path $node)) { $node = "node" } + & $node (Join-Path $dir "lib\dist\index.js") @args + exit $LASTEXITCODE + EOF + # PowerShell accepts LF, but ship CRLF like the .cmd (and the repo's *.ps1 eol=crlf attribute). + sed -i 's/$/\r/' out/penguin/bin/penguin.ps1 + (cd out && zip -qr ../dist-artifacts/penguin-win32-x64.zip penguin) + + # SHA256SUMS summary + a same-named .sha256 per artifact (install.sh / install.ps1 verify against the latter). - name: Generate SHA256 checksums run: | cd dist-artifacts - sha256sum *.tar.gz > SHA256SUMS - for f in *.tar.gz; do + sha256sum *.tar.gz *.zip > SHA256SUMS + for f in *.tar.gz *.zip; do sha256sum "$f" > "$f.sha256" done @@ -192,9 +233,11 @@ jobs: generate_release_notes: ${{ steps.notes.outputs.generate }} files: | dist-artifacts/*.tar.gz + dist-artifacts/*.zip dist-artifacts/*.sha256 dist-artifacts/SHA256SUMS install.sh + install.ps1 publish-npm: name: Publish npm packages diff --git a/.prettierrc.json b/.prettierrc.json index de753c5..b65a2da 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,3 +1,4 @@ { - "printWidth": 100 + "printWidth": 100, + "endOfLine": "lf" } diff --git a/README.md b/README.md index ead1609..93a4fd9 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Each family's latest generation only — the app's **Models** page lists every b | Requirement | Supported | | ------------ | -------------------------------------------------------------------------- | -| OS | Linux, macOS | +| OS | Linux, macOS, Windows 10+ | | Architecture | x64, arm64 | | Runtime | bundled by the one-line installer (npm installs need Node >= 24) | | Model | an API key for at least one model | @@ -117,6 +117,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh penguin web # start the service and open http://127.0.0.1:7364 (first login: admin / penguin-2026) ``` +🪟 On Windows (PowerShell): `irm https://penguin.ooo/install.ps1 | iex` + 📦 Or via npm: `npm install -g @prismshadow/penguin-cli`. Configure models on the in-app Models page, then chat. ### 🤖 CLI & SDK — for agents @@ -149,7 +151,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi - [ ] Public release of the benchmark suite - [ ] Desktop app -- [ ] Windows support +- [x] Windows support - [ ] Agent company and templates - [ ] Company-level self evolving - [ ] OpenShell integration (permission-governed shell) diff --git a/README.zh.md b/README.zh.md index 31e9172..ca4900d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -101,7 +101,7 @@ https://github.com/user-attachments/assets/aec49ae9-b743-467b-b247-37bedfeaa36e | 需求项 | 支持情况 | | -------- | ------------------------------------------------- | -| 操作系统 | Linux、macOS | +| 操作系统 | Linux、macOS、Windows 10+ | | 架构 | x64、arm64 | | 运行时 | 一行安装器自带(经 npm 安装需 Node >= 24) | | 模型 | 至少一个模型的 API key | @@ -117,6 +117,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh penguin web # 启动服务并打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026) ``` +🪟 Windows(PowerShell):`irm https://penguin.ooo/install.ps1 | iex` + 📦 或经 npm 安装:`npm install -g @prismshadow/penguin-cli`。在应用内模型页配置模型后即可对话。 ### 🤖 CLI 与 SDK——面向 Agent @@ -149,7 +151,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi - [ ] Benchmark 套件正式发布 - [ ] 桌面端应用 -- [ ] Windows 系统支持 +- [x] Windows 系统支持 - [ ] Agent 公司与模板 - [ ] 公司级自进化能力 - [ ] 集成 OpenShell(带权限管控的 shell) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..dfaa1ba --- /dev/null +++ b/install.ps1 @@ -0,0 +1,220 @@ +# PenguinHarness one-line installer for Windows. +# +# irm https://penguin.ooo/install.ps1 | iex +# +# Options: +# $env:PENGUIN_VERSION = "vX.Y.Z" pin a version (same as -Version vX.Y.Z); default is the latest Release +# $env:PENGUIN_INSTALL_DIR = "" install dir; default $env:USERPROFILE\.penguin +# +# There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run +# `npm install -g @prismshadow/penguin-cli` instead. +# +# The data dir (%USERPROFILE%\.penguin\data) sits under the install home but is never touched by +# reinstall/upgrade (which only replace bin/lib/web/node). Upgrading = re-running this installer. +# +# Docs: https://penguin.ooo/docs/installation +param( + [string]$Version = "", + [string]$InstallDir = "" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering slows downloads massively on PS 5.1 + +$Repo = "https://github.com/Prism-Shadow/penguin-harness" +$Asset = "penguin-win32-x64.zip" + +function Fail([string]$Message) { + # `throw` rather than `exit`: the penguin.ooo forwarder runs this installer as an in-memory + # script block (see packages/landing/public/install.ps1), where `exit` would terminate the + # user's whole PowerShell session. `throw` aborts cleanly in both file and script-block runs. + throw "error: $Message" +} + +# --- Resolve options (parameters win over env vars, mirroring install.sh's --version) --- +if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } } +if (-not $InstallDir) { + $InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".penguin" } +} + +# --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) --- +if (-not [Environment]::Is64BitOperatingSystem) { + Fail "32-bit Windows is not supported. Install Node.js >= 24 and use: npm install -g @prismshadow/penguin-cli" +} +if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + Write-Host "note: no native ARM64 package yet; installing the x64 package (runs via emulation)." +} + +# PowerShell 5.1 defaults to TLS 1.0 on older systems; GitHub requires TLS 1.2+. +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { + # .NET builds where the enum is immutable already default to TLS 1.2+. +} + +# --- Download (latest Release by default; PENGUIN_VERSION pins a version) --- +if ($Version) { + $BaseUrl = "$Repo/releases/download/$Version" +} else { + $BaseUrl = "$Repo/releases/latest/download" +} +$Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID" +if (Test-Path $Tmp) { Remove-Item -Recurse -Force $Tmp } +New-Item -ItemType Directory -Path $Tmp | Out-Null +# Pre-declare so the finally block can read them even when an early failure skipped the +# assignments (the user's session may run this under Set-StrictMode via the forwarder). +$Staging = $null +$OldDir = $null + +try { + Write-Host "Downloading $BaseUrl/$Asset ..." + $ZipPath = Join-Path $Tmp $Asset + try { + Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing + } catch { + Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))" + } + + # --- SHA256 verify: only when the .sha256 asset exists (skip on 404) --- + $ShaPath = Join-Path $Tmp "$Asset.sha256" + $HaveSha = $true + try { + Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing + } catch { + $HaveSha = $false + Write-Host "warning: checksum file not available; skipping verification." + } + if ($HaveSha) { + # The .sha256 file is ` ` (sha256sum format); the first token is the hash. + $Expected = ((Get-Content $ShaPath -Raw).Trim() -split "\s+")[0] + $Actual = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash + if ($Expected -and ($Actual -ieq $Expected)) { + Write-Host "Checksum OK." + } else { + Fail "checksum mismatch for $Asset." + } + } + + # --- Extract and swap into place: expand into a staging dir under the install dir (same volume, + # so the swap below is cheap renames), then rename-then-delete: move the old dirs aside first + # (a locked file fails fast here, before anything is deleted), move the new ones in, then + # drop the old. The data dir (%USERPROFILE%\.penguin\data) is untouched. --- + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $Staging = Join-Path $InstallDir ".staging.$PID" + $OldDir = Join-Path $InstallDir ".old.$PID" + if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging } + if (Test-Path $OldDir) { Remove-Item -Recurse -Force $OldDir } + New-Item -ItemType Directory -Path $Staging | Out-Null + + Write-Host "Extracting ..." + Expand-Archive -Path $ZipPath -DestinationPath $Staging -Force + $NewRoot = Join-Path $Staging "penguin" + if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." } + if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." } + + $Dirs = @("bin", "lib", "web", "node") + $Moved = @() + New-Item -ItemType Directory -Path $OldDir | Out-Null + try { + foreach ($d in $Dirs) { + $Existing = Join-Path $InstallDir $d + if (Test-Path $Existing) { + Move-Item -Path $Existing -Destination (Join-Path $OldDir $d) + $Moved += $d + } + } + } catch { + # Roll the already-moved dirs back so a locked install stays intact and usable. + foreach ($d in $Moved) { + Move-Item -Path (Join-Path $OldDir $d) -Destination (Join-Path $InstallDir $d) -ErrorAction SilentlyContinue + } + Fail "files in $InstallDir are locked: close running penguin processes (and any node.exe they started), then retry. ($($_.Exception.Message))" + } + foreach ($d in $Dirs) { + $Src = Join-Path $NewRoot $d + if (Test-Path $Src) { + Move-Item -Path $Src -Destination (Join-Path $InstallDir $d) + } + } + Remove-Item -Recurse -Force $OldDir -ErrorAction SilentlyContinue + if (Test-Path $OldDir) { + Write-Host "warning: could not fully remove $OldDir (files in use); delete it after closing running penguin processes." + } +} finally { + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue + if ($Staging -and (Test-Path $Staging)) { Remove-Item -Recurse -Force $Staging -ErrorAction SilentlyContinue } +} + +# --- Launcher shims: shipped in the zip; (re)generate only when missing --- +$CmdShim = Join-Path $InstallDir "bin\penguin.cmd" +if (-not (Test-Path $CmdShim)) { + @( + '@echo off' + 'setlocal' + 'set "DIR=%~dp0.."' + 'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"' + 'if exist "%DIR%\node\node.exe" (' + ' "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*' + ') else (' + ' node "%DIR%\lib\dist\index.js" %*' + ')' + 'exit /b %ERRORLEVEL%' + ) | Set-Content -Path $CmdShim -Encoding ascii +} +$Ps1Shim = Join-Path $InstallDir "bin\penguin.ps1" +if (-not (Test-Path $Ps1Shim)) { + @( + '$dir = Split-Path -Parent $PSScriptRoot' + 'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }' + '$node = Join-Path $dir "node\node.exe"' + 'if (-not (Test-Path $node)) { $node = "node" }' + '& $node (Join-Path $dir "lib\dist\index.js") @args' + 'exit $LASTEXITCODE' + ) | Set-Content -Path $Ps1Shim -Encoding ascii +} +if (-not (Test-Path $CmdShim)) { Fail "install incomplete: $CmdShim missing." } + +# --- User PATH: append \bin once; new terminals pick it up. +# Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable +# expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would +# irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw +# (unexpanded) value, append to it, and write it back with its original value kind. +# The registry only exists on Windows; skip the block elsewhere (functional test runs +# of this script on pwsh/Linux — where the old API was a silent no-op anyway). --- +$BinDir = Join-Path $InstallDir "bin" +if ($env:OS -eq "Windows_NT") { + $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true) + if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") } + try { + # Missing Path value: create it as REG_EXPAND_SZ (the kind Windows itself uses for Path). + $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + try { $Kind = $EnvKey.GetValueKind("Path") } catch {} + $RawPath = [string]$EnvKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + # Membership is checked per entry after expansion, so both literal and %VAR%-style + # spellings of the bin dir count as already present; the append itself stays raw. + $OnPath = @($RawPath -split ";" | Where-Object { $_ } | ForEach-Object { + [Environment]::ExpandEnvironmentVariables($_).TrimEnd("\") + }) -contains $BinDir.TrimEnd("\") + if (-not $OnPath) { + $NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" } + $EnvKey.SetValue("Path", $NewPath, $Kind) + Write-Host "" + Write-Host "note: appended $BinDir to your user Path. Restart your terminal so 'penguin' is found." + } + } finally { + $EnvKey.Close() + } +} +# Make `penguin` work in this session too. +if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" } + +# --- Finish: print version and getting-started tips --- +$InstalledVersion = "unknown" +try { $InstalledVersion = (& $CmdShim --version 2>$null | Select-Object -First 1) } catch {} +Write-Host "" +Write-Host "PenguinHarness $InstalledVersion installed to $InstallDir" +Write-Host "" +Write-Host "Get started:" +Write-Host " penguin --help # all commands" +Write-Host " penguin web # start the Web UI at http://127.0.0.1:7364 (initial login: admin / penguin-2026)" +Write-Host " penguin server # headless server (PORT / HOST to override)" diff --git a/package.json b/package.json index da6093a..66ce0de 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e", "build": "pnpm -r build && pnpm link:cli", "link:cli": "pnpm --dir packages/cli link --global || echo '[link:cli] pnpm global directory not configured; skipping the global penguin link (run pnpm setup once first)'", - "penguin": "PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx packages/cli/src/index.ts", + "penguin": "node scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx packages/cli/src/index.ts", "dev": "concurrently -n server,web -c cyan,magenta \"pnpm dev:server\" \"pnpm dev:web\"", "dev:server": "pnpm --filter @prismshadow/penguin-server dev", "dev:web": "node scripts/dev-prebuild.mjs && pnpm --filter @prismshadow/penguin-web dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 68dbdd5..c479969 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run --passWithNoTests", "build": "tsup", - "penguin": "PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx src/index.ts" + "penguin": "node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx src/index.ts" }, "dependencies": { "@prismshadow/agenthub": "^0.4.1", diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index b1fdc45..4dd42c5 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -325,6 +325,13 @@ export function registerConfigCommand(program: Command, t: Messages): void { process.exitCode = 1; return; } + // Windows has no POSIX shell startup file to write and no /bin shell to restart into; + // refuse with a clear pointer instead of an ENOENT from spawning /bin/zsh. + if (process.platform === "win32") { + process.stderr.write(`${getMessages(lang).langWindowsUnsupported(lang)}\n`); + process.exitCode = 1; + return; + } const { rcPath } = await applyLanguageToRc(lang, { shell: process.env.SHELL, home: homedir(), diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 0fe7c4e..f548963 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -176,6 +176,8 @@ export interface Messages { /** Example resume command shown when the REPL exits (dim print; only when this session has a resumable record). */ resumeHint(command: string): string; langInvalid(value: string): string; + /** `config lang` persists via POSIX shell startup files; on Windows it refuses with a pointer to a user env var instead. */ + langWindowsUnsupported(lang: string): string; langSet(lang: string, rcPath: string): string; langRestartConfirm(): string; langRestart(): string; @@ -383,6 +385,9 @@ const en: Messages = { `[resumed] ${sessionId} · ${messageCount} message${messageCount === 1 ? "" : "s"} in the current context`, resumeHint: (command) => `To continue this conversation: ${command}`, langInvalid: (value) => `Invalid language "${value}". Use en or zh.`, + langWindowsUnsupported: (lang) => + `penguin config lang persists via POSIX shell startup files, which Windows does not have.\n` + + `Set the user environment variable instead: setx PENGUIN_LANG ${lang} (new terminals pick it up).`, langSet: (lang, rcPath) => `Language set to ${lang}; wrote PENGUIN_LANG to ${rcPath}.`, langRestartConfirm: () => "Open a new shell now to apply? [y/N] ", langRestart: () => "Opening a new shell with the new language (type exit to return)…", @@ -550,6 +555,9 @@ const zh: Messages = { `[已恢复] ${sessionId} · 当前上下文共 ${messageCount} 条消息`, resumeHint: (command) => `继续本次对话:${command}`, langInvalid: (value) => `无效的语言 "${value}"。请使用 en 或 zh。`, + langWindowsUnsupported: (lang) => + `penguin config lang 通过 POSIX shell 启动文件持久化语言,Windows 上没有对应机制。\n` + + `请改为设置用户环境变量:setx PENGUIN_LANG ${lang}(新终端生效)。`, langSet: (lang, rcPath) => `语言已设为 ${lang};已将 PENGUIN_LANG 写入 ${rcPath}。`, langRestartConfirm: () => "现在打开新 shell 使其生效?[y/N] ", langRestart: () => "正在打开使用新语言的新 shell(输入 exit 可返回)……", diff --git a/packages/cli/test/config-model.test.ts b/packages/cli/test/config-model.test.ts index 7fe50bc..010c2d2 100644 --- a/packages/cli/test/config-model.test.ts +++ b/packages/cli/test/config-model.test.ts @@ -97,7 +97,10 @@ describe("penguin config model add/list (--root plus provider / model_id stored const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID); expect(path.basename(file)).toBe(".project_config.toml"); - expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute). + if (process.platform !== "win32") { + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + } const parsed = parseToml(await fs.readFile(file, "utf8")) as { models: Array>; }; diff --git a/packages/cli/test/config-vault.test.ts b/packages/cli/test/config-vault.test.ts index eeaa908..9f35faf 100644 --- a/packages/cli/test/config-vault.test.ts +++ b/packages/cli/test/config-vault.test.ts @@ -64,7 +64,10 @@ describe("penguin config vault", () => { const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, "default_agent"); expect(path.basename(file)).toBe(".vault.toml"); - expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute). + if (process.platform !== "win32") { + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + } expect(await fs.readFile(file, "utf8")).toContain("vault-secret-9876"); const list = await runVault(["list"]); diff --git a/packages/cli/test/lang-config.test.ts b/packages/cli/test/lang-config.test.ts index 71f2a4d..eba586a 100644 --- a/packages/cli/test/lang-config.test.ts +++ b/packages/cli/test/lang-config.test.ts @@ -5,26 +5,29 @@ import { afterEach, describe, expect, it } from "vitest"; import { applyLanguageToRc, resolveShellRc, upsertBlock } from "../src/lang-config.js"; describe("resolveShellRc", () => { + // rcPath is built with path.join, so the expectations join too (backslashes on Windows — + // where the `config lang` command refuses before ever calling this, but the pure function + // stays coherent). it("maps zsh / bash / fish to their startup files and syntax", () => { const zsh = resolveShellRc("/bin/zsh", "/home/u"); expect(zsh.kind).toBe("zsh"); - expect(zsh.rcPath).toBe("/home/u/.zshrc"); + expect(zsh.rcPath).toBe(join("/home/u", ".zshrc")); expect(zsh.body("zh")).toBe("export PENGUIN_LANG=zh"); const bash = resolveShellRc("/usr/bin/bash", "/home/u"); expect(bash.kind).toBe("bash"); - expect(bash.rcPath).toBe("/home/u/.bashrc"); + expect(bash.rcPath).toBe(join("/home/u", ".bashrc")); const fish = resolveShellRc("/usr/local/bin/fish", "/home/u"); expect(fish.kind).toBe("fish"); - expect(fish.rcPath).toBe("/home/u/.config/fish/config.fish"); + expect(fish.rcPath).toBe(join("/home/u", ".config", "fish", "config.fish")); expect(fish.body("en")).toBe("set -gx PENGUIN_LANG en"); }); it("falls back to ~/.profile for an unknown shell", () => { const rc = resolveShellRc(undefined, "/home/u"); expect(rc.kind).toBe("unknown"); - expect(rc.rcPath).toBe("/home/u/.profile"); + expect(rc.rcPath).toBe(join("/home/u", ".profile")); }); }); diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts index 5194ec8..2fef819 100644 --- a/packages/cli/test/update.test.ts +++ b/packages/cli/test/update.test.ts @@ -22,58 +22,64 @@ import { } from "../src/commands/update.js"; import { getMessages } from "../src/i18n.js"; -describe("detectInstall (how this CLI was installed, from its own real path)", () => { - it("tarball: /lib/dist/index.js, the layout install.sh unpacks", () => { - expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({ - kind: "tarball", - installDir: "/home/me/.penguin", +// POSIX-only: the fixtures are POSIX install layouts and detectInstall normalizes through +// path.* (backslashes on Windows) — where in-place `penguin update` is refused anyway +// (the documented Windows upgrade path is re-running install.ps1). +describe.skipIf(process.platform === "win32")( + "detectInstall (how this CLI was installed, from its own real path)", + () => { + it("tarball: /lib/dist/index.js, the layout install.sh unpacks", () => { + expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({ + kind: "tarball", + installDir: "/home/me/.penguin", + }); }); - }); - it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => { - expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({ - kind: "tarball", - installDir: "/opt/tools/penguin", + it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => { + expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({ + kind: "tarball", + installDir: "/opt/tools/penguin", + }); }); - }); - it("npm global: npm's own prefix layout", () => { - expect( - detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"), - ).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" }); - }); - - it("npm global: pnpm's global store, through the .pnpm virtual dir", () => { - const p = - "/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js"; - const info = detectInstall(p); - expect(info.kind).toBe("npm"); - expect(info.globalRoot).toContain(".pnpm"); - }); - - it("source checkout: the built dist inside the monorepo", () => { - expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({ - kind: "source", + it("npm global: npm's own prefix layout", () => { + expect( + detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"), + ).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" }); }); - }); - it("source checkout: tsx running src directly", () => { - expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({ - kind: "source", + it("npm global: pnpm's global store, through the .pnpm virtual dir", () => { + const p = + "/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js"; + const info = detectInstall(p); + expect(info.kind).toBe("npm"); + expect(info.globalRoot).toContain(".pnpm"); }); - }); - it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => { - expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({ - kind: "source", + it("source checkout: the built dist inside the monorepo", () => { + expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({ + kind: "source", + }); }); - }); - it("anything else is unknown rather than guessed", () => { - expect(detectInstall("/random/place/index.js").kind).toBe("unknown"); - expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown"); - }); -}); + it("source checkout: tsx running src directly", () => { + expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({ + kind: "source", + }); + }); + + it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => { + expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({ + kind: "source", + }); + }); + + it("anything else is unknown rather than guessed", () => { + expect(detectInstall("/random/place/index.js").kind).toBe("unknown"); + expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown"); + }); + }, +); describe("detectPackageManager (which manager owns a global node_modules root)", () => { it("pnpm: the global store or the .pnpm virtual dir", () => { diff --git a/packages/core/package.json b/packages/core/package.json index 4be59f9..96d4b08 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,7 +36,7 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run --passWithNoTests", - "test:e2e": "PENGUIN_E2E=1 vitest run test/llm.e2e.test.ts", + "test:e2e": "node ../../scripts/run-with-env.mjs PENGUIN_E2E=1 -- vitest run test/llm.e2e.test.ts", "build": "tsup" }, "dependencies": { diff --git a/packages/core/src/environment/tools/command/index.ts b/packages/core/src/environment/tools/command/index.ts index d94f444..7c55bf4 100644 --- a/packages/core/src/environment/tools/command/index.ts +++ b/packages/core/src/environment/tools/command/index.ts @@ -4,6 +4,8 @@ export { CommandSessionManager } from "./session-manager.js"; export { ManagedSession, resultForExit } from "./session.js"; export type { ProcessExit, SpawnOptions } from "./session.js"; +export { resolveShell, sessionShell } from "./shell.js"; +export type { ShellInvocation, ResolveShellOptions } from "./shell.js"; export { DEFAULT_EXEC_YIELD_MS, DEFAULT_WRITE_YIELD_MS, diff --git a/packages/core/src/environment/tools/command/session.ts b/packages/core/src/environment/tools/command/session.ts index 6cf1dd4..92f7c49 100644 --- a/packages/core/src/environment/tools/command/session.ts +++ b/packages/core/src/environment/tools/command/session.ts @@ -1,11 +1,14 @@ /** * ManagedSession — runtime state and collection logic for a single command session. * - * Spawns the process with `bash -lc `, with stdout/stderr going through plain pipes (no + * Spawns the process with `bash -lc ` (on Windows, the shell picked by `sessionShell()` + * — see shell.ts), with stdout/stderr going through plain pipes (no * native dependency, clean output; an interactive program that detects no TTY falls back to * non-interactive mode, which parses more cleanly for the Agent anyway). `detached` makes the * child process the process-group leader, so both Ctrl-C and killing the whole group rely on * **process-group signals** (sending a signal to `-pid` also reaches background child processes). + * Windows has neither process groups nor real signals: every "signal" degrades to a hard + * TerminateProcess, and tree-wide cleanup goes through `taskkill /t` instead (see signalGroup). * * Key semantics: * - **Termination is determined by the foreground process exiting (the exit event, waitpid @@ -22,9 +25,10 @@ * - `kill()` sends SIGTERM to the process group, then SIGKILL after a grace period, reaping any * leftover background child processes; idempotent. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import type { ToolResult } from "../types.js"; import { CappedTextBuffer, WakeSignal } from "../background/index.js"; +import { sessionShell } from "./shell.js"; /** Process-group semantics are available on POSIX; Windows falls back to signaling the child process directly. */ const SUPPORTS_PROCESS_GROUP = process.platform !== "win32"; @@ -48,7 +52,7 @@ export interface ProcessExit { /** Arguments required to start a command. */ export interface SpawnOptions { - /** Command string handed to `bash -lc`. */ + /** Command string handed to the session shell (`bash -lc` on POSIX; see shell.ts for Windows). */ cmd: string; /** Working directory (absolute path). */ cwd: string; @@ -71,11 +75,13 @@ export class ManagedSession { private readonly wakeSignal = new WakeSignal(); constructor(opts: SpawnOptions) { - this.child = spawn("bash", ["-lc", opts.cmd], { + const shell = sessionShell(); + this.child = spawn(shell.command, [...shell.args, opts.cmd], { cwd: opts.cwd, env: opts.env, detached: SUPPORTS_PROCESS_GROUP, // Become the process-group leader, so the whole group can be signaled stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, // No flashing console window on Windows (ignored elsewhere) }); this.child.stdout?.setEncoding("utf8"); this.child.stderr?.setEncoding("utf8"); @@ -92,11 +98,26 @@ export class ManagedSession { this.child.on("error", (err) => this.handleError(err)); } - /** Signals the process group; ignores the case where the process/group has already exited (ESRCH). */ - private signalGroup(sig: NodeJS.Signals): void { + /** + * Signals the process group; ignores the case where the process/group has already exited (ESRCH). + * + * Windows has no signals: `child.kill()` is an unconditional TerminateProcess of the direct + * child only, which would orphan grandchildren (a `node server.js` started by the shell). + * Every signal therefore becomes a hard kill of the whole tree via `taskkill /t /f` — including + * SIGINT: without a shared console there is no way to deliver a real Ctrl-C to a piped child, + * so input_command's Ctrl-C degrades to this hard kill on Windows. `sync` uses spawnSync for + * the process-'exit' fallback, where the event loop is no longer running. + */ + private signalGroup(sig: NodeJS.Signals, sync = false): void { try { if (SUPPORTS_PROCESS_GROUP && typeof this.child.pid === "number" && this.child.pid > 0) { process.kill(-this.child.pid, sig); // Negative pid = the whole process group + } else if ( + process.platform === "win32" && + typeof this.child.pid === "number" && + this.child.pid > 0 + ) { + this.killTreeWindows(this.child.pid, sync); } else { this.child.kill(sig); } @@ -105,6 +126,26 @@ export class ManagedSession { } } + /** Hard-kills the whole process tree on Windows; falls back to child.kill() if taskkill itself fails. */ + private killTreeWindows(pid: number, sync: boolean): void { + const args = ["/pid", String(pid), "/t", "/f"]; + if (sync) { + const res = spawnSync("taskkill", args, { stdio: "ignore", windowsHide: true }); + if (res.error || res.status !== 0) this.child.kill(); + return; + } + try { + const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true }); + // taskkill missing or failing (e.g. restricted environment): still terminate the direct child. + killer.on("error", () => this.child.kill()); + killer.on("exit", (code) => { + if (code !== 0 && !this.exited) this.child.kill(); + }); + } catch { + this.child.kill(); + } + } + private handleData(chunk: string): void { this.buffer.append(chunk); this.wakeSignal.notify(); @@ -191,6 +232,7 @@ export class ManagedSession { // stdin may already be closed, ignored. } } + /** Ctrl-C. POSIX: SIGINT to the process group; Windows: degrades to a hard tree kill (see signalGroup). */ interrupt(): void { this.lastUsed = Date.now(); this.signalGroup("SIGINT"); @@ -214,7 +256,7 @@ export class ManagedSession { clearTimeout(this.killTimer); this.killTimer = null; } - this.signalGroup("SIGKILL"); + this.signalGroup("SIGKILL", true); } } diff --git a/packages/core/src/environment/tools/command/shell.ts b/packages/core/src/environment/tools/command/shell.ts new file mode 100644 index 0000000..0a87054 --- /dev/null +++ b/packages/core/src/environment/tools/command/shell.ts @@ -0,0 +1,121 @@ +/** + * Shell selection for command sessions. + * + * POSIX behavior is unchanged: commands run via `bash -lc `. On Windows there is no + * bash by default, so the resolver picks the best available shell once per process: + * + * 1. `PENGUIN_SHELL` (explicit executable name or path) always wins, on every platform; + * the argument shape is inferred from its basename (see below). + * 2. Otherwise, non-Windows uses `bash -lc` (today's behavior, bit for bit). + * 3. On Windows, probe PATH for `bash` (Git for Windows — best compatibility with the + * skill/prompt ecosystem, which is written for a POSIX shell), then `pwsh` + * (PowerShell 7+), then fall back to `powershell` (Windows PowerShell 5.1, always + * present). A `bash` that resolves into the Windows system directory is ignored: that + * is the WSL launcher, which runs commands inside a Linux distro with a different + * filesystem view (and fails outright when no distro is configured). + * + * Argument shapes by basename (also applied to `PENGUIN_SHELL` values): + * - `pwsh` / `powershell` -> `-NoLogo -NoProfile -Command ` + * - `cmd` -> `/d /s /c ` + * - anything else -> `-lc ` (bash/zsh/sh-style login shell) + * + * The resolved shell's name is surfaced to the model via the session environment (the + * `Shell:` line in the system prompt), so it knows which syntax the exec tool speaks. + */ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +/** A resolved shell invocation: `spawn(command, [...args, cmd])` runs `cmd` in that shell. */ +export interface ShellInvocation { + /** Executable name or absolute path handed to spawn(). */ + command: string; + /** Fixed argument prefix placed before the command string. */ + args: string[]; + /** Short lowercase name (basename without extension), shown to the model (e.g. "bash", "pwsh"). */ + name: string; +} + +/** Injection points for unit tests; production callers use the defaults. */ +export interface ResolveShellOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + /** Returns the PATH resolutions of an executable name, best match first ([] when not found). */ + whichAll?: (cmd: string) => string[]; + /** The Windows system root (to recognize the WSL bash launcher); default `env.SystemRoot` or C:\Windows. */ + systemRoot?: string; +} + +/** Basename without a trailing .exe/.cmd/.bat/.ps1 extension, lowercased ("C:\...\pwsh.EXE" -> "pwsh"). */ +function shellBasename(command: string): string { + // path.win32 handles both separators, so PENGUIN_SHELL=/usr/bin/zsh still yields "zsh". + return path.win32 + .basename(command) + .replace(/\.(exe|cmd|bat|ps1)$/i, "") + .toLowerCase(); +} + +/** Argument prefix for a shell, chosen by its basename (PowerShell-style vs cmd vs POSIX-style). */ +function argsForShell(name: string): string[] { + if (name === "pwsh" || name === "powershell") return ["-NoLogo", "-NoProfile", "-Command"]; + if (name === "cmd") return ["/d", "/s", "/c"]; + return ["-lc"]; +} + +/** Default PATH probe: `where` lists every match line by line, in PATH order (win32 only). */ +function defaultWhichAll(cmd: string): string[] { + try { + const res = spawnSync("where", [cmd], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + if (res.status !== 0 || !res.stdout) return []; + return res.stdout + .toString("utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } catch { + return []; + } +} + +/** + * Resolves the shell for command sessions (pure given its options; see the module comment + * for the order). Exported for unit tests; runtime code uses the cached `sessionShell()`. + */ +export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation { + const platform = opts.platform ?? process.platform; + const env = opts.env ?? process.env; + + const explicit = env.PENGUIN_SHELL?.trim(); + if (explicit) { + const name = shellBasename(explicit); + return { command: explicit, args: argsForShell(name), name }; + } + + if (platform !== "win32") { + return { command: "bash", args: ["-lc"], name: "bash" }; + } + + const whichAll = opts.whichAll ?? defaultWhichAll; + const systemRoot = opts.systemRoot ?? env.SystemRoot ?? "C:\\Windows"; + // The WSL launcher lives in \System32 (or Sysnative under WOW64); a Git for + // Windows bash lives under the Git install dir. Only the first PATH match counts — that + // is the one spawn("bash") would run. + const bash = whichAll("bash")[0]; + if (bash && !bash.toLowerCase().startsWith(systemRoot.toLowerCase() + path.win32.sep)) { + return { command: "bash", args: ["-lc"], name: "bash" }; + } + if (whichAll("pwsh").length > 0) { + return { command: "pwsh", args: argsForShell("pwsh"), name: "pwsh" }; + } + return { command: "powershell", args: argsForShell("powershell"), name: "powershell" }; +} + +let cached: ShellInvocation | null = null; + +/** The process-wide shell for command sessions; resolved once (probing PATH costs a subprocess on Windows). */ +export function sessionShell(): ShellInvocation { + if (!cached) cached = resolveShell(); + return cached; +} diff --git a/packages/core/src/internal/session-support.ts b/packages/core/src/internal/session-support.ts index 40fdaec..4fd29f8 100644 --- a/packages/core/src/internal/session-support.ts +++ b/packages/core/src/internal/session-support.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { randomBytes, randomUUID } from "node:crypto"; import { formatLocalDate } from "./dates.js"; +import { sessionShell } from "../environment/tools/command/shell.js"; import type { SessionEnvironmentValues } from "../state/agent-state.js"; import { workspacesDir } from "../state/index.js"; import { userText } from "../omnimessage/index.js"; @@ -47,6 +48,9 @@ export function sessionEnvironment( modelId: ids.modelId, platform: process.platform, osVersion: getOsVersion(), + // The shell exec_command actually runs (bash on POSIX; resolved on Windows): the model + // must know whether to write bash or PowerShell syntax. + shell: sessionShell().name, date: formatLocalDate(date), }; } diff --git a/packages/core/src/state/agent-state.ts b/packages/core/src/state/agent-state.ts index 0a74f5b..f3a95e8 100644 --- a/packages/core/src/state/agent-state.ts +++ b/packages/core/src/state/agent-state.ts @@ -36,6 +36,7 @@ import { PLATFORM_PLACEHOLDER, PROJECT_DIR_PLACEHOLDER, SESSION_ID_PLACEHOLDER, + SHELL_PLACEHOLDER, type SystemConfig, } from "./default-config.js"; import { builtinProjectAgentPresets, type AgentPreset } from "./builtin-agents.js"; @@ -92,6 +93,8 @@ export interface SessionEnvironmentValues { modelId: string; platform: string; osVersion: string; + /** The shell command sessions run in (system Prompt placeholder {{SHELL}}; e.g. "bash", "pwsh") — tells the model which command syntax exec_command speaks. */ + shell: string; date: string; } @@ -383,6 +386,43 @@ export function skillMetadataSection(skills: SkillMetadata[]): string { .join("\n"); } +/** + * Windows compatibility fallback for pre-`{{SHELL}}` system prompt templates. + * + * `system_config.yaml` is baked at Agent creation and never auto-upgraded, so an Agent created + * before the `{{SHELL}}` placeholder existed never tells its model which shell `exec_command` + * speaks — and on Windows (where the shell may be PowerShell, not bash) the model then keeps + * emitting bash syntax into the wrong shell. When the platform is win32 and the template carries + * no `{{SHELL}}` placeholder, inject a `- Shell: ` line into the assembled prompt at + * render time: right after the Environment section heading when one exists, else appended as a + * minimal final line. In-memory only — the stored template is never rewritten. On POSIX the + * assembled prompt stays byte-identical (bash was always implied there), and a prompt that + * already carries the exact line is left untouched (idempotent). + * + * Retirement condition: remove this fallback once pre-`{{SHELL}}` Agent configs (created before + * the placeholder shipped in PR #79) are no longer expected in the wild. + */ +function withShellLineFallback( + assembled: string, + template: string, + sessionEnvironment?: SessionEnvironmentValues, +): string { + if (sessionEnvironment?.platform !== "win32") return assembled; + if (!sessionEnvironment.shell) return assembled; + if (template.includes(SHELL_PLACEHOLDER)) return assembled; // The template already renders the line itself. + // Any existing `- Shell:` line means the model is already told a shell — including a custom + // template hardcoding a different value on purpose; never add a second, contradicting line. + if (/^- Shell: /m.test(assembled)) return assembled; + const line = `- Shell: ${sessionEnvironment.shell}`; + // The default templates head the section with `# Environment`; accept any heading level. + const heading = /^#+ Environment[ \t]*$/m.exec(assembled); + if (heading) { + const insertAt = heading.index + heading[0].length; + return `${assembled.slice(0, insertAt)}\n${line}${assembled.slice(insertAt)}`; + } + return `${assembled}\n${line}`; +} + /** * Renders the complete runtime system Prompt: substitutes `AGENTS.md`, vault key names, Skill * metadata, and the concrete Session runtime environment placeholders into the system Prompt @@ -390,7 +430,8 @@ export function skillMetadataSection(skills: SkillMetadata[]): string { * wrapper text such as `[developer_instructions]` and the # Vault / # Skills statements are * written directly into the system Prompt template itself (the Prompt is fully * transparent and editable via `system_config.yaml`). Other files in Agent State / Workspace are - * never auto-injected. + * never auto-injected. Sole exception: on win32 a template without `{{SHELL}}` gets a `- Shell:` + * line injected at render time (see `withShellLineFallback`). * * `{{VAULT_KEYS}}` is replaced with the vault key-name list (an empty string if empty/not * provided): this lets the model know which APIs requiring a key it can call; values are never @@ -406,7 +447,8 @@ export function assembleSystemPrompt( vaultKeys?: string[], skillMetadata?: SkillMetadata[], ): string { - return state.systemConfig.system_prompt + const template = state.systemConfig.system_prompt; + const assembled = template .split(AGENTS_MD_PLACEHOLDER) .join(state.agentsMd.trim()) .split(VAULT_KEYS_PLACEHOLDER) @@ -429,9 +471,12 @@ export function assembleSystemPrompt( .join(sessionEnvironment?.platform ?? "") .split(OS_VERSION_PLACEHOLDER) .join(sessionEnvironment?.osVersion ?? "") + .split(SHELL_PLACEHOLDER) + .join(sessionEnvironment?.shell ?? "") .split(DATE_PLACEHOLDER) .join(sessionEnvironment?.date ?? "") .trim(); + return withShellLineFallback(assembled, template, sessionEnvironment); } /** diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts index c68d1a7..2857dba 100644 --- a/packages/core/src/state/default-config.ts +++ b/packages/core/src/state/default-config.ts @@ -33,6 +33,8 @@ export const PROVIDER_PLACEHOLDER = "{{PROVIDER}}"; export const MODEL_ID_PLACEHOLDER = "{{MODEL_ID}}"; export const PLATFORM_PLACEHOLDER = "{{PLATFORM}}"; export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}"; +/** The shell exec_command runs (`bash` on POSIX; on Windows whatever shell.ts resolved), so the model knows which command syntax to write. */ +export const SHELL_PLACEHOLDER = "{{SHELL}}"; export const DATE_PLACEHOLDER = "{{DATE}}"; /** @@ -147,6 +149,7 @@ Skills are reusable instruction packages stored under /agents/ { modelId: "deepseek-v4-pro", platform: "linux", osVersion: "test", + shell: "bash", date: "2026-07-08", }); expect(prompt).toContain("Agent ID: env_agent"); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 0123f4c..eece206 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -132,8 +132,10 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => { }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); - await rm(traces, { recursive: true, force: true }); + // Retries (here and in the other cleanups below): on Windows a just-killed process tree + // releases its cwd locks asynchronously, so an immediate rm can hit EBUSY. + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); it("approves a tool call, writes the file, returns the final answer, traces it", async () => { @@ -733,7 +735,7 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", ( workspace = await mkdtemp(join(tmpdir(), "penguin-ws2-")); }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); it("emits both tool calls in one round; second is approved while the first executes; outputs come back in completion order", async () => { @@ -809,7 +811,13 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", ( // command has not finished yet) -- i.e., execution does not block the next approval. expect(approvedAt["t2"]!).toBeLessThan(firstCompleteAt["t1"] ?? Infinity); // The fast b.txt finishes first, the slow a.txt finishes later (outputs in completion order). - expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!); + // POSIX only: on Windows a cold Git-Bash spawn costs 1-2s, which can swamp the 400ms sleep + // delta that makes t1 "the slow one" — CI has seen the two complete within 5ms — so the + // relative completion order is not controllable there. The overlap assertion above and the + // file contents below still run on Windows. + if (process.platform !== "win32") { + expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!); + } expect(await readFile(join(workspace, "a.txt"), "utf8")).toBe("one"); expect(await readFile(join(workspace, "b.txt"), "utf8")).toBe("two"); @@ -895,7 +903,7 @@ describe("ContextEngine tool execution resilience", () => { workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-")); }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); it("feeds a failed tool output back and keeps tool_use/result paired (Environment converges errors, never throws)", async () => { @@ -1105,7 +1113,7 @@ describe("ContextEngine abort during execution", () => { workspace = await mkdtemp(join(tmpdir(), "penguin-ws3-")); }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); it("aborting a long-running tool ends the turn, emits abort, and carries tool results over (model output completed)", async () => { @@ -1255,7 +1263,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => { workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-")); }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); it("auto-retries on LLM timeout: original input + [turn_retried] carrying partial products", async () => { @@ -1792,8 +1800,8 @@ describe("ContextEngine mid-run steering ([user_steering])", () => { }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); - await rm(traces, { recursive: true, force: true }); + await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); /** Fake environment: streams a delta then closes with a fixed complete output (no real shell). */ diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts index 1b3e835..8f90b99 100644 --- a/packages/core/test/environment.test.ts +++ b/packages/core/test/environment.test.ts @@ -67,7 +67,9 @@ beforeEach(async () => { afterEach(async () => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; - await rm(tmp, { recursive: true, force: true }); + // Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously, + // so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff. + await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); describe("Environment.listTools", () => { @@ -485,9 +487,12 @@ describe("Environment.executeTool — relaxed tool contract", () => { describe("Environment.executeTool — timeoutMs (PRN-013)", () => { it("fails a tool exceeding timeoutMs, keeps prior output, and streams the timeout reason", async () => { + // The timeout must stay below MIN_YIELD_MS (250): for larger values exec_command yields to + // background (with a process_id) before the Environment timeout can ever fire. + const timeoutMs = 200; const env = new Environment({ workspaceDir: tmp, - toolConfig: makeToolConfig(execTool({ timeoutMs: 200 })), + toolConfig: makeToolConfig(execTool({ timeoutMs })), }); const startedAt = Date.now(); @@ -513,8 +518,13 @@ describe("Environment.executeTool — timeoutMs (PRN-013)", () => { }; expect(last.type).toBe("tool_call_output"); expect(last.stop_reason).toBe("failed"); - expect(last.output).toContain("begin"); - expect(last.output).toContain("[tool timeout: exceeded 200ms]"); + // Kept-prior-output is asserted only where the shell can win the race: a Git-Bash login + // shell on Windows needs several hundred ms to start, so nothing is printed before a + // sub-250ms timeout there — the timeout mechanics above are still fully exercised. + if (process.platform !== "win32") { + expect(last.output).toContain("begin"); + } + expect(last.output).toContain(`[tool timeout: exceeded ${timeoutMs}ms]`); // The timeout marker is also produced via streaming: concatenating the streamed deltas == // the complete content. const streamed = messages @@ -735,14 +745,14 @@ describe("Environment.executeTool — robustness", () => { describe("Environment.toolPermission", () => { it("returns the configured permission for a known tool", () => { const env = new Environment({ - workspaceDir: "/tmp", + workspaceDir: tmpdir(), toolConfig: makeToolConfig(execTool({ permission: "rw" })), }); expect(env.toolPermission("exec_command")).toBe("rw"); }); it("returns undefined for an unknown tool", () => { - const env = new Environment({ workspaceDir: "/tmp", toolConfig: makeToolConfig() }); + const env = new Environment({ workspaceDir: tmpdir(), toolConfig: makeToolConfig() }); expect(env.toolPermission("nope")).toBeUndefined(); }); }); diff --git a/packages/core/test/exec-session.test.ts b/packages/core/test/exec-session.test.ts index bf379e6..200e7da 100644 --- a/packages/core/test/exec-session.test.ts +++ b/packages/core/test/exec-session.test.ts @@ -91,7 +91,9 @@ beforeEach(async () => { afterEach(async () => { env.dispose(); - await rm(tmp, { recursive: true, force: true }); + // Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously, + // so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff. + await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); describe("exec_command — long-running command sessions", () => { diff --git a/packages/core/test/file-tools.test.ts b/packages/core/test/file-tools.test.ts index f15445a..e12292b 100644 --- a/packages/core/test/file-tools.test.ts +++ b/packages/core/test/file-tools.test.ts @@ -514,15 +514,19 @@ describe("edit_file — review follow-ups", () => { describe("write_file — review follow-ups", () => { const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw")); - it("preserves the permission bits of an overwritten file (atomic rename)", async () => { - const file = path.join(tmp, "mode.txt"); - await writeFile(file, "old"); - await chmod(file, 0o600); - const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp); - expect(result?.stopReason).toBeUndefined(); - expect((await stat(file)).mode & 0o777).toBe(0o600); - expect(await readFile(file, "utf8")).toBe("new"); - }); + // POSIX-only: Windows has no owner-only mode bits to preserve (chmod maps to the read-only attribute). + it.skipIf(process.platform === "win32")( + "preserves the permission bits of an overwritten file (atomic rename)", + async () => { + const file = path.join(tmp, "mode.txt"); + await writeFile(file, "old"); + await chmod(file, 0o600); + const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp); + expect(result?.stopReason).toBeUndefined(); + expect((await stat(file)).mode & 0o777).toBe(0o600); + expect(await readFile(file, "utf8")).toBe("new"); + }, + ); it("writes atomically: no temp files are left behind", async () => { await run(tool(), { file_path: "fresh.txt", content: "x" }, tmp); diff --git a/packages/core/test/shell-resolver.test.ts b/packages/core/test/shell-resolver.test.ts new file mode 100644 index 0000000..2a0bdbb --- /dev/null +++ b/packages/core/test/shell-resolver.test.ts @@ -0,0 +1,112 @@ +/** + * Unit tests for the command-session shell resolver (pure function; platform, env and the + * PATH probe are injected — no real shells are spawned here). + */ +import { describe, expect, it } from "vitest"; +import { resolveShell } from "../src/environment/tools/command/shell.js"; + +const POWERSHELL_ARGS = ["-NoLogo", "-NoProfile", "-Command"]; + +/** A whichAll stub resolving only the given names (value = returned PATH matches). */ +function which(table: Record): (cmd: string) => string[] { + return (cmd) => table[cmd] ?? []; +} + +describe("resolveShell — POSIX", () => { + it("uses bash -lc on linux without probing (today's behavior, unchanged)", () => { + let probed = false; + const shell = resolveShell({ + platform: "linux", + env: {}, + whichAll: () => { + probed = true; + return []; + }, + }); + expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" }); + expect(probed).toBe(false); + }); + + it("uses bash -lc on darwin", () => { + const shell = resolveShell({ platform: "darwin", env: {} }); + expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" }); + }); +}); + +describe("resolveShell — win32 probing", () => { + it("prefers bash on PATH (Git for Windows)", () => { + const shell = resolveShell({ + platform: "win32", + env: {}, + whichAll: which({ + bash: ["C:\\Program Files\\Git\\bin\\bash.exe"], + pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"], + }), + }); + expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" }); + }); + + it("skips the WSL launcher bash under the system root and falls through to pwsh", () => { + const shell = resolveShell({ + platform: "win32", + env: { SystemRoot: "C:\\WINDOWS" }, + whichAll: which({ + bash: ["C:\\Windows\\System32\\bash.exe"], + pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"], + }), + }); + expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" }); + }); + + it("falls back to pwsh when bash is absent", () => { + const shell = resolveShell({ + platform: "win32", + env: {}, + whichAll: which({ pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"] }), + }); + expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" }); + }); + + it("falls back to powershell when neither bash nor pwsh resolve", () => { + const shell = resolveShell({ platform: "win32", env: {}, whichAll: which({}) }); + expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" }); + }); +}); + +describe("resolveShell — PENGUIN_SHELL override", () => { + it("wins on every platform and keeps POSIX-style args for a POSIX shell path", () => { + const shell = resolveShell({ + platform: "linux", + env: { PENGUIN_SHELL: "/usr/bin/zsh" }, + }); + expect(shell).toEqual({ command: "/usr/bin/zsh", args: ["-lc"], name: "zsh" }); + }); + + it("uses PowerShell-style args when the basename is pwsh (case/extension-insensitive)", () => { + const shell = resolveShell({ + platform: "win32", + env: { PENGUIN_SHELL: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE" }, + whichAll: which({ bash: ["C:\\Program Files\\Git\\bin\\bash.exe"] }), + }); + expect(shell).toEqual({ + command: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE", + args: POWERSHELL_ARGS, + name: "pwsh", + }); + }); + + it("uses PowerShell-style args for a bare powershell name", () => { + const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "powershell" } }); + expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" }); + }); + + it("uses cmd-style args when the basename is cmd", () => { + const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "cmd" } }); + expect(shell).toEqual({ command: "cmd", args: ["/d", "/s", "/c"], name: "cmd" }); + }); + + it("ignores a blank PENGUIN_SHELL", () => { + const shell = resolveShell({ platform: "linux", env: { PENGUIN_SHELL: " " } }); + expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" }); + }); +}); diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index d3fa9b5..e0b9c53 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -16,6 +16,7 @@ import { PLATFORM_PLACEHOLDER, PROJECT_DIR_PLACEHOLDER, SESSION_ID_PLACEHOLDER, + SHELL_PLACEHOLDER, addModel, setVisionModel, agentsMdPath, @@ -64,7 +65,10 @@ afterEach(async () => { } else { process.env.PENGUIN_HOME = prevHome; } - await fs.rm(tmpRoot, { recursive: true, force: true }); + // Retries: when a test times out, vitest runs this cleanup while the test's un-cancelled + // init may still be writing files, so an immediate recursive rm can hit ENOTEMPTY on + // Windows (fs.rm retries ENOTEMPTY/EBUSY/EPERM); a no-op when removal succeeds first try. + await fs.rm(tmpRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); async function exists(p: string): Promise { @@ -83,87 +87,96 @@ describe("paths / resolveRoot", () => { }); describe("loadOrInitAgentState", () => { - it("initializes an empty agent directory with the full state layout", async () => { - const state = await loadOrInitAgentState(); - expect(state.root).toBe(tmpRoot); - expect(state.projectId).toBe(DEFAULT_PROJECT_ID); - expect(state.agentId).toBe(DEFAULT_AGENT_ID); + // Timeout: initialization writes the full layout — 15 library skills plus the example + // benchmark, dozens of small files — and this first init test also pays the cold-I/O cost + // (first-touch reads of the skills package, Defender scans) on Windows runners, where a + // slow-disk moment has pushed it past the 5s default. Purely a failure deadline: passing + // runs stay as fast as before on every platform. + it( + "initializes an empty agent directory with the full state layout", + { timeout: 20_000 }, + async () => { + const state = await loadOrInitAgentState(); + expect(state.root).toBe(tmpRoot); + expect(state.projectId).toBe(DEFAULT_PROJECT_ID); + expect(state.agentId).toBe(DEFAULT_AGENT_ID); - const root = tmpRoot; - expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - // The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id). - expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + const root = tmpRoot; + expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); + // The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id). + expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true); - expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)); + expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)); - // The default system Prompt states the Agent's identity, without repeating tool details - // already in the tool schema (Suggested workflows only points to the run_subagent - // delegation entry point). - expect(state.systemConfig.system_prompt).toContain("PenguinHarness"); - expect(state.systemConfig.system_prompt).not.toContain("exec_command"); - // Suggested workflows absorbs Subagent delegation and task conventions (self-reported - // identity as a soft convention, parallelism, file exchange). - expect(state.systemConfig.system_prompt).toContain("# Suggested workflows"); - expect(state.systemConfig.system_prompt).toContain("run_subagent"); - expect(state.systemConfig.system_prompt).toContain("Caller agent"); - // The default AGENTS.md is empty: it carries no preset guidance. - expect(state.agentsMd).toBe(""); - expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER); - expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER); - expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER); - expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER); - expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER); - expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER); - // AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md - // before Environment; the [developer_instructions] wrapper text is written directly into - // the template (the Prompt is transparent about the config). - expect(state.systemConfig.system_prompt).toContain("[developer_instructions]"); - expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]"); - // The default template explains the semantics of system-synthesized markers to the model, - // and recommends preferring tool use. - expect(state.systemConfig.system_prompt).toContain("[turn_aborted]"); - expect(state.systemConfig.system_prompt).toContain("[turn_retried]"); - expect(state.systemConfig.system_prompt).toContain("[context_summary]"); - expect(state.systemConfig.system_prompt).toContain("[user_steering]"); - expect(state.systemConfig.system_prompt).toContain("# Tool use"); - // Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file, - // which holds API keys) and each Agent's .vault.toml, and states that config can only be - // changed via the CLI (penguin config ...). - expect(state.systemConfig.system_prompt).toContain("Never read"); - expect(state.systemConfig.system_prompt).toContain(".project_config.toml"); - expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml"); - expect(state.systemConfig.system_prompt).toContain("CLI-only"); - expect(state.systemConfig.system_prompt).toContain("penguin config"); - expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml"); - expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan( - state.systemConfig.system_prompt.indexOf("# Environment"), - ); - // The # Vault and # Skills body sections plus their placeholders: the default template - // places them after [/developer_instructions] and before # Environment, in the order - // Vault -> Skills (the statement text is part of the template body, kept even with no - // keys/skills). - const tpl = state.systemConfig.system_prompt; - expect(tpl).toContain("# Vault"); - expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER); - expect(tpl).toContain("# Skills"); - expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER); - expect(tpl).toContain("[use_skills]"); - expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault")); - expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)); - expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills")); - expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)); - expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment")); - expect(state.systemConfig.model?.max_tokens).toBe(32000); - expect(state.systemConfig.model?.thinking_level).toBe("medium"); - expect(state.systemConfig.model?.timeoutMs).toBe(120000); - expect(state.systemConfig.tools?.mcpServers).toEqual([]); - expect(Object.hasOwn(state.systemConfig, "description")).toBe(false); - expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false); - }); + // The default system Prompt states the Agent's identity, without repeating tool details + // already in the tool schema (Suggested workflows only points to the run_subagent + // delegation entry point). + expect(state.systemConfig.system_prompt).toContain("PenguinHarness"); + expect(state.systemConfig.system_prompt).not.toContain("exec_command"); + // Suggested workflows absorbs Subagent delegation and task conventions (self-reported + // identity as a soft convention, parallelism, file exchange). + expect(state.systemConfig.system_prompt).toContain("# Suggested workflows"); + expect(state.systemConfig.system_prompt).toContain("run_subagent"); + expect(state.systemConfig.system_prompt).toContain("Caller agent"); + // The default AGENTS.md is empty: it carries no preset guidance. + expect(state.agentsMd).toBe(""); + expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER); + expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER); + expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER); + expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER); + expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER); + expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER); + // AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md + // before Environment; the [developer_instructions] wrapper text is written directly into + // the template (the Prompt is transparent about the config). + expect(state.systemConfig.system_prompt).toContain("[developer_instructions]"); + expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]"); + // The default template explains the semantics of system-synthesized markers to the model, + // and recommends preferring tool use. + expect(state.systemConfig.system_prompt).toContain("[turn_aborted]"); + expect(state.systemConfig.system_prompt).toContain("[turn_retried]"); + expect(state.systemConfig.system_prompt).toContain("[context_summary]"); + expect(state.systemConfig.system_prompt).toContain("[user_steering]"); + expect(state.systemConfig.system_prompt).toContain("# Tool use"); + // Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file, + // which holds API keys) and each Agent's .vault.toml, and states that config can only be + // changed via the CLI (penguin config ...). + expect(state.systemConfig.system_prompt).toContain("Never read"); + expect(state.systemConfig.system_prompt).toContain(".project_config.toml"); + expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml"); + expect(state.systemConfig.system_prompt).toContain("CLI-only"); + expect(state.systemConfig.system_prompt).toContain("penguin config"); + expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml"); + expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan( + state.systemConfig.system_prompt.indexOf("# Environment"), + ); + // The # Vault and # Skills body sections plus their placeholders: the default template + // places them after [/developer_instructions] and before # Environment, in the order + // Vault -> Skills (the statement text is part of the template body, kept even with no + // keys/skills). + const tpl = state.systemConfig.system_prompt; + expect(tpl).toContain("# Vault"); + expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER); + expect(tpl).toContain("# Skills"); + expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER); + expect(tpl).toContain("[use_skills]"); + expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault")); + expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)); + expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills")); + expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)); + expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment")); + expect(state.systemConfig.model?.max_tokens).toBe(32000); + expect(state.systemConfig.model?.thinking_level).toBe("medium"); + expect(state.systemConfig.model?.timeoutMs).toBe(120000); + expect(state.systemConfig.tools?.mcpServers).toEqual([]); + expect(Object.hasOwn(state.systemConfig, "description")).toBe(false); + expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false); + }, + ); it("loads an existing agent directory and returns the same system prompt", async () => { const first = await loadOrInitAgentState(); @@ -500,6 +513,7 @@ describe("assembleSystemPrompt", () => { `pdir=${PROJECT_DIR_PLACEHOLDER}`, `platform=${PLATFORM_PLACEHOLDER}`, `os=${OS_VERSION_PLACEHOLDER}`, + `shell=${SHELL_PLACEHOLDER}`, `date=${DATE_PLACEHOLDER}`, "middle", AGENTS_MD_PLACEHOLDER, @@ -518,6 +532,7 @@ describe("assembleSystemPrompt", () => { modelId: "deepseek-v4-pro", platform: "darwin", osVersion: "Darwin 25.0.0", + shell: "zsh", date: "2026-06-30", }); expect(prompt).toBe( @@ -529,6 +544,7 @@ describe("assembleSystemPrompt", () => { "pdir=/tmp/proj", "platform=darwin", "os=Darwin 25.0.0", + "shell=zsh", "date=2026-06-30", "middle", "# Agent Rules\nFollow local rules.", @@ -572,6 +588,7 @@ describe("assembleSystemPrompt", () => { modelId: "deepseek-v4-pro", platform: "darwin", osVersion: "Darwin 25.0.0", + shell: "zsh", date: "2026-06-30", }); expect(prompt).toBe("base prompt"); @@ -657,9 +674,11 @@ describe("assembleSystemPrompt", () => { expect(prompt).toContain("Model ID: gpt-5.5"); expect(prompt).toContain("Platform:"); expect(prompt).toContain("OS Version:"); + expect(prompt).toContain("Shell:"); expect(prompt).toContain("Date: 2026-06-30"); expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:")); - expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:")); + expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Shell:")); + expect(prompt.indexOf("Shell:")).toBeLessThan(prompt.indexOf("Date:")); expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("App Data Dir:")); expect(prompt.indexOf("App Data Dir:")).toBeLessThan(prompt.indexOf("Agent ID:")); expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("CWD:")); @@ -667,6 +686,114 @@ describe("assembleSystemPrompt", () => { expect(prompt.indexOf("Provider:")).toBeLessThan(prompt.indexOf("Model ID:")); expect(prompt.indexOf("Model ID:")).toBeLessThan(prompt.indexOf("Session ID:")); }); + + // The win32 Shell-line fallback for pre-{{SHELL}} templates (system_config.yaml is baked at + // Agent creation and never auto-upgraded). Removable together with `withShellLineFallback` + // once pre-{{SHELL}} Agent configs are no longer expected in the wild. + describe("Shell line fallback for templates without {{SHELL}}", () => { + const stateWithPrompt = (system_prompt: string) => ({ + root: tmpRoot, + projectId: DEFAULT_PROJECT_ID, + agentId: DEFAULT_AGENT_ID, + stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), + systemConfig: { system_prompt }, + agentsMd: "", + }); + const envFor = (platform: string) => ({ + sessionId: "session-1", + cwd: "C:\\ws", + agentId: "agent-x", + projectDir: "C:\\proj", + provider: "deepseek", + modelId: "deepseek-v4-pro", + platform, + osVersion: "Windows 11 Pro 10.0.26100", + shell: "pwsh", + date: "2026-07-27", + }); + // A pre-{{SHELL}} default-template Environment section (Platform/OS Version/Date, no Shell). + const preShellTemplate = [ + "intro", + "# Environment", + `- Platform: ${PLATFORM_PLACEHOLDER}`, + `- OS Version: ${OS_VERSION_PLACEHOLDER}`, + `- Date: ${DATE_PLACEHOLDER}`, + "", + "# Tail section", + "tail", + ].join("\n"); + + it("injects the line exactly once into the Environment section on win32", () => { + const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), envFor("win32")); + expect(prompt).toBe( + [ + "intro", + "# Environment", + "- Shell: pwsh", + "- Platform: win32", + "- OS Version: Windows 11 Pro 10.0.26100", + "- Date: 2026-07-27", + "", + "# Tail section", + "tail", + ].join("\n"), + ); + expect(prompt.split("- Shell: pwsh").length - 1).toBe(1); + }); + + it("keeps POSIX output byte-identical (no injected line)", () => { + for (const platform of ["linux", "darwin"]) { + const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), { + ...envFor(platform), + shell: "bash", + osVersion: "Linux 6.1.0", + }); + expect(prompt).toBe( + [ + "intro", + "# Environment", + `- Platform: ${platform}`, + "- OS Version: Linux 6.1.0", + "- Date: 2026-07-27", + "", + "# Tail section", + "tail", + ].join("\n"), + ); + expect(prompt).not.toContain("Shell:"); + } + }); + + it("does not duplicate the line when the template has {{SHELL}}", () => { + const template = [ + "# Environment", + `- Platform: ${PLATFORM_PLACEHOLDER}`, + `- Shell: ${SHELL_PLACEHOLDER}`, + ].join("\n"); + const prompt = assembleSystemPrompt(stateWithPrompt(template), envFor("win32")); + expect(prompt).toBe(["# Environment", "- Platform: win32", "- Shell: pwsh"].join("\n")); + expect(prompt.split("- Shell:").length - 1).toBe(1); + }); + + it("does not duplicate a hardcoded line and appends a minimal line without an Environment section", () => { + // A custom template that hardcodes the exact line: left untouched (idempotent). + const hardcoded = assembleSystemPrompt( + stateWithPrompt("base prompt\n- Shell: pwsh"), + envFor("win32"), + ); + expect(hardcoded).toBe("base prompt\n- Shell: pwsh"); + // A hardcoded line with a different value is a deliberate template choice: + // never add a second, contradicting Shell line. + const pinned = assembleSystemPrompt( + stateWithPrompt("base prompt\n- Shell: bash"), + envFor("win32"), + ); + expect(pinned).toBe("base prompt\n- Shell: bash"); + // No Environment section at all: the line is appended at the end. + const appended = assembleSystemPrompt(stateWithPrompt("base prompt"), envFor("win32")); + expect(appended).toBe("base prompt\n- Shell: pwsh"); + }); + }); }); describe("resetSystemConfigToDefaults", () => { @@ -1174,28 +1301,35 @@ describe("single hidden config file (.project_config.toml, credentials inlined)" // The sole config file is hidden (not shown by ls by default) and has 0600 permission // (owner read/write only). expect(path.basename(file)).toBe(".project_config.toml"); - expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute). + if (process.platform !== "win32") { + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + } expect(await fs.readFile(file, "utf8")).toContain("sk-split-1"); // The old two-file layout is no longer produced. expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, "project_config.toml"))).toBe(false); expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, ".credentials.toml"))).toBe(false); }); - it("chmod converges an existing file back to 0600 on save", async () => { - await addModel(tmpRoot, DEFAULT_PROJECT_ID, { - provider: "custom", - model_id: "m-perm", - api_key: "sk-1", - }); - const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID); - await fs.chmod(file, 0o644); - await addModel(tmpRoot, DEFAULT_PROJECT_ID, { - provider: "custom", - model_id: "m-perm", - api_key: "sk-2", - }); - expect((await fs.stat(file)).mode & 0o777).toBe(0o600); - }); + // POSIX-only: Windows has no owner-only mode bits to converge. + it.skipIf(process.platform === "win32")( + "chmod converges an existing file back to 0600 on save", + async () => { + await addModel(tmpRoot, DEFAULT_PROJECT_ID, { + provider: "custom", + model_id: "m-perm", + api_key: "sk-1", + }); + const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID); + await fs.chmod(file, 0o644); + await addModel(tmpRoot, DEFAULT_PROJECT_ID, { + provider: "custom", + model_id: "m-perm", + api_key: "sk-2", + }); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + }, + ); it("writes provider and model_id as separate fields; refs are TOML inline tables", async () => { await addModel( @@ -1238,7 +1372,10 @@ describe("agent vault (agent_state/.vault.toml)", () => { // with 0600 permission (owner read/write only). const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID); expect(path.basename(file)).toBe(".vault.toml"); - expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute). + if (process.platform !== "win32") { + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + } const raw = await fs.readFile(file, "utf8"); expect(raw).toContain("sk-secret-2"); // The Project config no longer carries the vault. diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..3638d30 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,16 @@ +/** + * Vitest config for core. The one non-default setting is a platform-aware test timeout: + * many core tests spawn a real shell (Git-Bash on the Windows CI runners) or write the + * full Agent State layout, and Windows runner I/O variance (cold shell spawns, Defender + * first-touch scans, slow-disk moments) has pushed individually fast tests past vitest's + * 5s default — a different test on each run. A larger failure deadline changes nothing + * for passing tests; POSIX keeps the 5s default to fail fast during local development. + */ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + testTimeout: process.platform === "win32" ? 30_000 : 5_000, + }, +}); diff --git a/packages/docs/content/installation.en.md b/packages/docs/content/installation.en.md index 218e12f..371b457 100644 --- a/packages/docs/content/installation.en.md +++ b/packages/docs/content/installation.en.md @@ -6,6 +6,7 @@ description: Install PenguinHarness via the install script, npm, or from source. ## Requirements - Linux / macOS (x64 or arm64): the install script ships platform tarballs with an official Node.js runtime bundled — no local Node needed. +- Windows 10 or later (x64) with PowerShell 5.1+: the Windows installer ships `penguin-win32-x64.zip` with the runtime bundled — no local Node needed. - Other platforms, or installing via npm / from source: system Node.js >= 24. ## Script install (recommended) @@ -16,7 +17,19 @@ On Linux / macOS: curl -fsSL https://penguin.ooo/install.sh | sh ``` -The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz`, which bundles an official Node.js runtime. Other platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz`. +The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz`, which bundles an official Node.js runtime. Other POSIX platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz` (Windows is served by its own installer below, not by `--universal`). + +On Windows (PowerShell): + +```powershell +irm https://penguin.ooo/install.ps1 | iex +``` + +To pin a version, set the env var first: + +```powershell +$env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex +``` Verify the install: @@ -36,9 +49,25 @@ penguin -v Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`. +### Windows specifics + +| Item | Details | +| --- | --- | +| Install dir | `%USERPROFILE%\.penguin` by default; override with the `PENGUIN_INSTALL_DIR` env var | +| Command entry | `bin\penguin.cmd` and `bin\penguin.ps1` launchers; the installer adds `%USERPROFILE%\.penguin\bin` to your **user** Path (restart the terminal once) | +| Version pin | `$env:PENGUIN_VERSION = "vX.Y.Z"` before running the installer | +| Integrity check | Downloads are sha256-verified when the Release ships checksum assets | +| Upgrade | Re-run the installer; it swaps `bin`/`lib`/`web`/`node` and never touches `data` | + +- **Agent shell**: on Windows, the agent's `exec_command` prefers Git-Bash (`bash` on PATH, e.g. from [Git for Windows](https://gitforwindows.org/)) for the best compatibility with skills written for a POSIX shell, and falls back to PowerShell (`pwsh`, then `powershell`) when bash is absent. The `PENGUIN_SHELL` env var overrides the pick; the session's system prompt tells the model which shell is active. +- **Ctrl-C semantics**: on Windows, sending Ctrl-C to a running command session (`input_command` with `"\u0003"`) terminates the whole command session tree instead of interrupting the foreground command — Windows cannot deliver a console Ctrl-C to a piped child process, so the interrupt degrades to a hard tree kill. +- **In-place update**: `penguin update` is not yet supported on Windows — upgrade by re-running the installer above. +- **Config file permissions**: on POSIX, config/credential files are written with `0600` (owner-only) permissions; Windows has no such mode bits, so files fall under your profile's default NTFS ACLs. +- If PowerShell refuses to run `penguin` with "running scripts is disabled", your execution policy blocks the `penguin.ps1` shim: either call `penguin.cmd` explicitly, or allow local scripts with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. + ### Data directory -The data directory defaults to `~/.penguin/data` — under the install home `~/.penguin`, but never modified by install or upgrade — and is overridable with the `PENGUIN_HOME` env var. Model configuration, Session records, and other data are preserved across upgrades. +The data directory defaults to `~/.penguin/data` (`%USERPROFILE%\.penguin\data` on Windows) — under the install home, but never modified by install or upgrade — and is overridable with the `PENGUIN_HOME` env var. Model configuration, Session records, and other data are preserved across upgrades. ## npm install @@ -48,7 +77,7 @@ Requires system Node.js >= 24: npm install -g @prismshadow/penguin-cli ``` -The npm package is `@prismshadow/penguin-cli`; the installed command is `penguin`. Web UI assets ship inside the `@prismshadow/penguin-server` package, so this single install yields a working `penguin web`. +The npm package is `@prismshadow/penguin-cli`; the installed command is `penguin`. Web UI assets ship inside the `@prismshadow/penguin-server` package, so this single install yields a working `penguin web`. This route works on every platform (including Windows) and is the alternative when the packaged zip/tarball is unsuitable. ## From source diff --git a/packages/docs/content/installation.zh.md b/packages/docs/content/installation.zh.md index 51d51d7..502c5de 100644 --- a/packages/docs/content/installation.zh.md +++ b/packages/docs/content/installation.zh.md @@ -6,6 +6,7 @@ description: 通过安装脚本、npm 或源码安装 PenguinHarness。 ## 系统要求 - Linux / macOS(x64 或 arm64):安装脚本提供内置官方 Node.js 运行时的平台压缩包,解压即用,无需本机安装 Node。 +- Windows 10 及以上(x64),PowerShell 5.1+:Windows 安装器提供内置运行时的 `penguin-win32-x64.zip`,同样无需本机安装 Node。 - 其他平台,或通过 npm / 源码安装:需要系统 Node.js >= 24。 ## 脚本安装(推荐) @@ -16,7 +17,19 @@ description: 通过安装脚本、npm 或源码安装 PenguinHarness。 curl -fsSL https://penguin.ooo/install.sh | sh ``` -脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`,其中捆绑了官方 Node.js 运行时。其他平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz`。 +脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`,其中捆绑了官方 Node.js 运行时。其他 POSIX 平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz`(Windows 使用下方专属安装器,而不是 `--universal`)。 + +在 Windows(PowerShell)上执行: + +```powershell +irm https://penguin.ooo/install.ps1 | iex +``` + +如需固定版本,先设置环境变量: + +```powershell +$env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex +``` 安装完成后验证: @@ -36,9 +49,25 @@ penguin -v 脚本参数写在 `sh -s --` 之后,例如 `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`。 +### Windows 细节 + +| 项目 | 说明 | +| --- | --- | +| 安装目录 | 默认 `%USERPROFILE%\.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 | +| 命令入口 | `bin\penguin.cmd` 与 `bin\penguin.ps1` 启动器;安装器会把 `%USERPROFILE%\.penguin\bin` 加入**用户** Path(重启终端后生效) | +| 版本固定 | 运行安装器前设置 `$env:PENGUIN_VERSION = "vX.Y.Z"` | +| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 | +| 升级 | 重新运行安装器;只替换 `bin`/`lib`/`web`/`node`,绝不触碰 `data` | + +- **Agent shell**:Windows 上 `exec_command` 优先使用 Git-Bash(PATH 上的 `bash`,如 [Git for Windows](https://gitforwindows.org/)),与面向 POSIX shell 编写的技能生态兼容性最好;没有 bash 时回退到 PowerShell(先 `pwsh` 后 `powershell`)。环境变量 `PENGUIN_SHELL` 可强制指定;会话的系统提示词会告知模型当前 shell。 +- **Ctrl-C 语义**:Windows 上向运行中的命令会话发送 Ctrl-C(`input_command` 传 `"\u0003"`)会终止整棵命令会话进程树,而不是中断前台命令——Windows 无法向管道子进程投递控制台 Ctrl-C,中断因此退化为整树强杀。 +- **就地更新**:`penguin update` 暂不支持 Windows——升级请重新运行上面的安装器。 +- **配置文件权限**:POSIX 上配置/凭据文件以 `0600`(仅属主可读写)写入;Windows 没有对应的权限位,文件遵循你用户目录的默认 NTFS ACL。 +- 如果 PowerShell 提示 "running scripts is disabled" 而无法运行 `penguin`,是执行策略拦住了 `penguin.ps1`:可以显式调用 `penguin.cmd`,或用 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` 允许本地脚本。 + ### 数据目录 -数据目录默认位于 `~/.penguin/data`(在安装主目录 `~/.penguin` 之下,但安装与升级都不会改动它),可用环境变量 `PENGUIN_HOME` 覆盖。模型配置、Session 记录等在升级后均会保留。 +数据目录默认位于 `~/.penguin/data`(Windows 为 `%USERPROFILE%\.penguin\data`),在安装主目录之下,但安装与升级都不会改动它,可用环境变量 `PENGUIN_HOME` 覆盖。模型配置、Session 记录等在升级后均会保留。 ## npm 安装 @@ -48,7 +77,7 @@ penguin -v npm install -g @prismshadow/penguin-cli ``` -npm 包名为 `@prismshadow/penguin-cli`,安装后的命令是 `penguin`。Web UI 静态资源随 `@prismshadow/penguin-server` 包发布,因此仅执行上述命令即可直接使用 `penguin web`。 +npm 包名为 `@prismshadow/penguin-cli`,安装后的命令是 `penguin`。Web UI 静态资源随 `@prismshadow/penguin-server` 包发布,因此仅执行上述命令即可直接使用 `penguin web`。该方式在所有平台(含 Windows)可用,是压缩包不适用时的替代路径。 ## 源码安装 diff --git a/packages/docs/content/tools.en.md b/packages/docs/content/tools.en.md index e928491..8d4d397 100644 --- a/packages/docs/content/tools.en.md +++ b/packages/docs/content/tools.en.md @@ -116,6 +116,8 @@ Both tools' arguments (explicit keys): } ``` +On POSIX, Ctrl-C sends `SIGINT` to the session's process group, interrupting the foreground command. On Windows there is no console signal delivery to a piped child process, so Ctrl-C degrades to a hard kill of the whole command session tree (`taskkill /t /f`) — the foreground command and every child it started terminate, instead of the foreground command being interrupted. + ### File tools `read_file` / `edit_file` / `write_file` run with the user's full permissions, same as the shell tool; relative paths resolve against the Workspace and absolute paths are allowed. They are non-streaming (a single final output) and never throw — failures come back as explanatory text with `stop_reason: failed`. diff --git a/packages/docs/content/tools.zh.md b/packages/docs/content/tools.zh.md index e0ce45c..0600934 100644 --- a/packages/docs/content/tools.zh.md +++ b/packages/docs/content/tools.zh.md @@ -115,6 +115,8 @@ exec_command(cmd) } ``` +POSIX 上 Ctrl-C 向会话进程组发送 `SIGINT`,中断前台命令。Windows 无法向管道子进程投递控制台信号,Ctrl-C 因此退化为整棵命令会话进程树的强杀(`taskkill /t /f`)——前台命令及其启动的所有子进程一并终止,而不是仅中断前台命令。 + ### 文件工具 `read_file` / `edit_file` / `write_file` 与 Shell 工具一样以用户完整权限运行:相对路径按 Workspace 解析,也接受绝对路径。三者均为非流式(一次性输出最终结果),从不抛异常——失败以解释性文本收尾,`stop_reason` 为 `failed`。 diff --git a/packages/landing/public/install.ps1 b/packages/landing/public/install.ps1 new file mode 100644 index 0000000..d9eed1e --- /dev/null +++ b/packages/landing/public/install.ps1 @@ -0,0 +1,33 @@ +# https://penguin.ooo/install.ps1 - PenguinHarness installer entry point for Windows. +# +# GitHub Pages cannot serve HTTP redirects, so this thin forwarder IS the +# stable install URL: it fetches the real installer attached to the latest +# GitHub release and runs it, forwarding every argument it was given. Usage: +# +# irm https://penguin.ooo/install.ps1 | iex +# & ([scriptblock]::Create((irm https://penguin.ooo/install.ps1))) -Version v0.2.0 +# +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { + # .NET builds where the enum is immutable already default to TLS 1.2+. +} +# Download fully first, then run: executing a piped stream directly would run a +# truncated download line by line, and the real installer moves the old +# bin/lib/web/node aside before moving the new ones in - a cut connection +# mid-way must never leave a half-executed installer. The installer runs as an +# in-memory script block (not a script file): script files are subject to the +# execution policy, which is Restricted by default on client Windows - while the +# user has already consented to remote code by piping this forwarder into iex. +# Neither this forwarder nor the installer calls `exit`, which in iex/script-block +# context would terminate the user's whole PowerShell session. +$Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID.ps1" +try { + Invoke-WebRequest -Uri "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.ps1" -OutFile $Tmp -UseBasicParsing + $Installer = [scriptblock]::Create((Get-Content -Path $Tmp -Raw)) + & $Installer @args +} finally { + Remove-Item -Force $Tmp -ErrorAction SilentlyContinue +} diff --git a/packages/landing/src/lib/links.ts b/packages/landing/src/lib/links.ts index f704214..2692ef9 100644 --- a/packages/landing/src/lib/links.ts +++ b/packages/landing/src/lib/links.ts @@ -18,6 +18,12 @@ export const DOCS_URL = `${import.meta.env.BASE_URL}docs/`; */ export const INSTALL_CMD = "curl -fsSL https://penguin.ooo/install.sh | sh"; +/** + * One-line installer for Windows (PowerShell 5.1+, x64, bundled Node runtime). + * penguin.ooo/install.ps1 is public/install.ps1 — the same thin-forwarder design. + */ +export const INSTALL_CMD_WINDOWS = "irm https://penguin.ooo/install.ps1 | iex"; + /** * Demo videos live in the sibling `penguin-harness-community` repo rather than in this * one: they are ~9 MB each, and this repo's whole history is ~17 MB, so committing them diff --git a/packages/landing/src/lib/strings-en.ts b/packages/landing/src/lib/strings-en.ts index ba9231d..9c03342 100644 --- a/packages/landing/src/lib/strings-en.ts +++ b/packages/landing/src/lib/strings-en.ts @@ -59,7 +59,9 @@ export const en: Strings = { ctaPrimary: "Get started", ctaGithub: "GitHub", installHint: - "One-line install (Linux / macOS, x64 / arm64, bundled Node runtime — unpack and run)", + "One-line install (Linux / macOS / Windows, bundled Node runtime — unpack and run)", + installLabelPosix: "Linux / macOS", + installLabelWindows: "Windows", stats: [ { value: "1000+", label: "supported models" }, { value: "1×CPU", label: "minimum footprint" }, @@ -155,7 +157,9 @@ export const en: Strings = { "Install with one command and let the Agent work from a desktop-grade interface — all data stays in your local ~/.penguin/data directory.", step1: "Install", step1Desc: - "Linux / macOS (x64 / arm64) with a bundled Node runtime — unpack and run; upgrades never touch your data.", + "Linux / macOS / Windows with a bundled Node runtime — unpack and run; upgrades never touch your data.", + installLabelPosix: "Linux / macOS", + installLabelWindows: "Windows (PowerShell)", tabWeb: "Web UI", tabCli: "CLI", webStep2: "Open the web interface", diff --git a/packages/landing/src/lib/strings.ts b/packages/landing/src/lib/strings.ts index 4b21440..2bb27cc 100644 --- a/packages/landing/src/lib/strings.ts +++ b/packages/landing/src/lib/strings.ts @@ -63,7 +63,9 @@ export const zh = { keywords: ["轻量", "高效", "开源"], ctaPrimary: "快速开始", ctaGithub: "GitHub", - installHint: "一行命令安装(Linux / macOS,x64 / arm64,内嵌 Node 运行时,解压即用)", + installHint: "一行命令安装(Linux / macOS / Windows,内嵌 Node 运行时,解压即用)", + installLabelPosix: "Linux / macOS", + installLabelWindows: "Windows", stats: [ { value: "1000+", label: "支持模型数量" }, { value: "1×CPU", label: "最低运行配置" }, @@ -156,8 +158,9 @@ export const zh = { subtitle: "一行命令安装,打开桌面级界面即可让 Agent 开始工作;数据全部保存在本地 ~/.penguin/data 目录。", step1: "安装", - step1Desc: - "Linux / macOS(x64 / arm64),产物内嵌 Node 运行时,解压即用;升级与重装不触碰数据。", + step1Desc: "Linux / macOS / Windows,产物内嵌 Node 运行时,解压即用;升级与重装不触碰数据。", + installLabelPosix: "Linux / macOS", + installLabelWindows: "Windows(PowerShell)", tabWeb: "Web 界面", tabCli: "命令行", webStep2: "启动 Web 界面", diff --git a/packages/landing/src/sections/hero.tsx b/packages/landing/src/sections/hero.tsx index 9a3803e..cf2b911 100644 --- a/packages/landing/src/sections/hero.tsx +++ b/packages/landing/src/sections/hero.tsx @@ -7,7 +7,7 @@ import { Fragment, useEffect, useState } from "react"; import { Link } from "react-router"; import { S } from "../lib/strings"; -import { INSTALL_CMD, REPO_URL } from "../lib/links"; +import { INSTALL_CMD, INSTALL_CMD_WINDOWS, REPO_URL } from "../lib/links"; import { CopyButton } from "../components/copy-button"; import { ArrowRightIcon, GitHubIcon } from "../components/icons"; @@ -115,12 +115,28 @@ export function Hero() { className="anim-rise mx-auto mt-10 w-fit max-w-full" style={{ animationDelay: "240ms" }} > -
- - $ - {INSTALL_CMD} - - + {/* One install row per OS (labels are language-neutral; prompt chars $ vs > match the shells). */} +
+
+ + {S.hero.installLabelPosix} + + + $ + {INSTALL_CMD} + + +
+
+ + {S.hero.installLabelWindows} + + + > + {INSTALL_CMD_WINDOWS} + + +

{S.hero.installHint}

diff --git a/packages/landing/src/sections/quickstart.tsx b/packages/landing/src/sections/quickstart.tsx index 5bc257c..cf291e0 100644 --- a/packages/landing/src/sections/quickstart.tsx +++ b/packages/landing/src/sections/quickstart.tsx @@ -7,7 +7,12 @@ import { useState } from "react"; import type { ReactNode } from "react"; import { S } from "../lib/strings"; -import { DEEPSEEK_KEYS_URL, INSTALL_CMD, OPENROUTER_KEYS_URL } from "../lib/links"; +import { + DEEPSEEK_KEYS_URL, + INSTALL_CMD, + INSTALL_CMD_WINDOWS, + OPENROUTER_KEYS_URL, +} from "../lib/links"; import { Section } from "../components/section"; import { CodeCard } from "../components/code-card"; import { @@ -125,7 +130,12 @@ export function Quickstart() { title={S.quickstart.step1} desc={S.quickstart.step1Desc} > - + + {mode === "web" ? ( diff --git a/packages/server/package.json b/packages/server/package.json index 7865ac8..b852a4b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -25,7 +25,7 @@ "node": ">=24" }, "scripts": { - "dev": "node ../../scripts/dev-prebuild.mjs && PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx watch src/index.ts", + "dev": "node ../../scripts/dev-prebuild.mjs && node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx watch src/index.ts", "start": "node --disable-warning=ExperimentalWarning dist/index.js", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run --passWithNoTests", diff --git a/packages/server/src/services/workspace-files-service.ts b/packages/server/src/services/workspace-files-service.ts index 139fecb..e70d25d 100644 --- a/packages/server/src/services/workspace-files-service.ts +++ b/packages/server/src/services/workspace-files-service.ts @@ -262,6 +262,15 @@ export class WorkspaceFilesService { } const { dir, name } = await this.resolveWriteParent(workspace, rel); const file = path.join(dir, name); + // Windows has no O_NOFOLLOW (the `?? 0` below erases it), so the atomic ELOOP guard never + // fires there — refuse a final-segment symlink via lstat instead. Best effort (a link + // created between this check and the open wins the race), but it closes the practical + // "preset a symlink, overwrite an outside file by upload" escape; POSIX keeps the atomic + // open-time guarantee. + if (process.platform === "win32") { + const st = await fs.lstat(file).catch(() => null); + if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink."); + } // O_NOFOLLOW: open reports ELOOP if the final segment is a symlink, refusing to use it as leverage to overwrite a file outside the sandbox. const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0); let handle; diff --git a/packages/server/test/models.test.ts b/packages/server/test/models.test.ts index 9340c20..f19e8cf 100644 --- a/packages/server/test/models.test.ts +++ b/packages/server/test/models.test.ts @@ -82,7 +82,10 @@ describe("models preset & catalog enrichment", () => { expect(cfgRaw).toContain('provider = "custom"'); expect(cfgRaw).toContain('model_id = "m-inline"'); expect(cfgRaw).not.toContain("custom/m-inline"); - expect((await stat(cfgFile)).mode & 0o777).toBe(0o600); + // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute). + if (process.platform !== "win32") { + expect((await stat(cfgFile)).mode & 0o777).toBe(0o600); + } // No more separate .credentials.toml / project_config.toml files. await expect(readFile(path.join(projectDir, ".credentials.toml"), "utf8")).rejects.toThrow(); await expect(readFile(path.join(projectDir, "project_config.toml"), "utf8")).rejects.toThrow(); diff --git a/packages/web/e2e/README.md b/packages/web/e2e/README.md index 1b28521..1d403bc 100644 --- a/packages/web/e2e/README.md +++ b/packages/web/e2e/README.md @@ -12,3 +12,8 @@ SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # skip the build ``` The first run requires `npx playwright install chromium`. + +The runner (`run.sh`) is POSIX-only and deliberately stays that way: `pnpm test` never +invokes it (only the separate `test:e2e` script does), so a Windows checkout builds and +tests fine without it. To run the browser e2e on Windows, use Git-Bash +(`bash e2e/run.sh` from `packages/web`). diff --git a/scripts/dev-prebuild.mjs b/scripts/dev-prebuild.mjs index 42894fe..29bb484 100644 --- a/scripts/dev-prebuild.mjs +++ b/scripts/dev-prebuild.mjs @@ -110,7 +110,12 @@ function ensureInstalled() { return true; } console.log("[dev-prebuild] dependencies missing or lockfile changed; running pnpm install..."); - const res = spawnSync("pnpm", ["install"], { cwd: ROOT, stdio: "inherit" }); + // shell on Windows: pnpm is a .cmd shim there, which Node refuses to spawn shell-less (CVE-2024-27980). + const res = spawnSync("pnpm", ["install"], { + cwd: ROOT, + stdio: "inherit", + shell: process.platform === "win32", + }); if (res.status !== 0) return false; writeFileSync(INSTALL_STAMP, hash); return true; @@ -156,7 +161,8 @@ try { "@prismshadow/penguin-core", "build", ], - { cwd: ROOT, stdio: "inherit" }, + // shell on Windows: pnpm is a .cmd shim there (see ensureInstalled). + { cwd: ROOT, stdio: "inherit", shell: process.platform === "win32" }, ); if (res.status === 0) writeFileSync(BUILD_STAMP, String(Date.now())); exitCode = res.status ?? 1; diff --git a/scripts/run-with-env.mjs b/scripts/run-with-env.mjs new file mode 100644 index 0000000..eeefb69 --- /dev/null +++ b/scripts/run-with-env.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * Cross-platform replacement for POSIX env-prefix script lines. + * + * node scripts/run-with-env.mjs VAR=value [VAR2=value2 ...] -- [args...] + * + * Each assignment sets VAR **only when it is unset or empty** in the current environment — + * the JS equivalent of `VAR="${VAR:-value}" cmd`, which cmd.exe cannot parse (package.json + * scripts run under cmd.exe on Windows). A leading `~/` in the value expands to the user's + * home directory (the `$HOME/...` defaults). Then the command runs with inherited stdio and + * its exit code is propagated. + * + * On Windows the command is a pnpm-installed shim (tsx.cmd, vitest.cmd), which Node refuses + * to spawn without a shell since CVE-2024-27980; those runs go through the shell with + * quoting applied. POSIX keeps a plain shell-less spawn (bit-for-bit today's behavior). + */ +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const argv = process.argv.slice(2); +const sep = argv.indexOf("--"); +if (sep === -1 || sep === argv.length - 1) { + console.error("usage: node scripts/run-with-env.mjs VAR=value [...] -- [args...]"); + process.exit(2); +} + +const env = { ...process.env }; +for (const assignment of argv.slice(0, sep)) { + const eq = assignment.indexOf("="); + if (eq <= 0) { + console.error(`run-with-env: not an assignment: ${assignment}`); + process.exit(2); + } + const name = assignment.slice(0, eq); + let value = assignment.slice(eq + 1); + if (value === "~" || value.startsWith("~/")) { + value = path.join(os.homedir(), value.slice(2)); + } + if (!env[name]) env[name] = value; // unset or empty -> default (the ${VAR:-value} rule) +} + +const [command, ...args] = argv.slice(sep + 1); +const windows = process.platform === "win32"; +// cmd.exe does not unquote spawn args by itself; quote anything with spaces or cmd +// metacharacters (best effort — cmd has no lossless general quoting, but dev-script args +// are file paths and short flags). +const quote = (a) => (/[\s"^&|<>;,()%!]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a); +const child = windows + ? spawn([command, ...args].map(quote).join(" "), { + stdio: "inherit", + env, + shell: true, + windowsHide: true, + }) + : spawn(command, args, { stdio: "inherit", env }); + +child.on("error", (err) => { + console.error(`run-with-env: failed to start ${command}: ${err.message}`); + process.exit(1); +}); +child.on("exit", (code, signal) => { + if (signal) { + // Mirror the shell convention for signal deaths so callers see a failure. + process.exit(1); + } + process.exit(code ?? 1); +});