From fd9417a248671cc8edc3c06f99e9e611baa20cc5 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sun, 21 Jun 2026 23:33:31 +0800 Subject: [PATCH 01/10] feat(runtime): expose isOpenTuiRuntimeSupported for Bun detection --- src/runtime/open-tui-capability.test.ts | 60 +++++++++++++++++++++++++ src/runtime/open-tui-capability.ts | 15 +++++++ 2 files changed, 75 insertions(+) create mode 100644 src/runtime/open-tui-capability.test.ts diff --git a/src/runtime/open-tui-capability.test.ts b/src/runtime/open-tui-capability.test.ts new file mode 100644 index 0000000..b3bc6d2 --- /dev/null +++ b/src/runtime/open-tui-capability.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + isOpenTuiEnabledInCurrentBuild, + isOpenTuiRuntimeSupported, + parseOpenTuiEnabledValue, +} from "./open-tui-capability.js"; + +const originalBunVersion = process.versions.bun; + +afterEach(() => { + // Restore process.versions.bun between tests so mocks don't leak. + if (originalBunVersion === undefined) { + delete (process.versions as { bun?: string }).bun; + } else { + (process.versions as { bun?: string }).bun = originalBunVersion; + } +}); + +describe("parseOpenTuiEnabledValue", () => { + it("returns true when value is undefined", () => { + expect(parseOpenTuiEnabledValue(undefined)).toBe(true); + }); + + it("returns false for '0' and 'false' (case-insensitive)", () => { + expect(parseOpenTuiEnabledValue("0")).toBe(false); + expect(parseOpenTuiEnabledValue("false")).toBe(false); + expect(parseOpenTuiEnabledValue("FALSE")).toBe(false); + expect(parseOpenTuiEnabledValue(" false ")).toBe(false); + }); + + it("returns true for any other value", () => { + expect(parseOpenTuiEnabledValue("1")).toBe(true); + expect(parseOpenTuiEnabledValue("true")).toBe(true); + }); +}); + +describe("isOpenTuiEnabledInCurrentBuild", () => { + it("reflects the STEP_CLI_ENABLE_OPENTUI env var parsed at module load", () => { + // Module was loaded with whatever env was active at import time; we just + // assert the function returns a boolean and does not throw. + expect(typeof isOpenTuiEnabledInCurrentBuild()).toBe("boolean"); + }); +}); + +describe("isOpenTuiRuntimeSupported", () => { + it("returns true when process.versions.bun is a version string", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + expect(isOpenTuiRuntimeSupported()).toBe(true); + }); + + it("returns false when process.versions.bun is undefined (Node.js)", () => { + delete (process.versions as { bun?: string }).bun; + expect(isOpenTuiRuntimeSupported()).toBe(false); + }); + + it("returns false when process.versions.bun is an empty string", () => { + (process.versions as { bun?: string }).bun = ""; + expect(isOpenTuiRuntimeSupported()).toBe(false); + }); +}); diff --git a/src/runtime/open-tui-capability.ts b/src/runtime/open-tui-capability.ts index 4866a9d..79a557a 100644 --- a/src/runtime/open-tui-capability.ts +++ b/src/runtime/open-tui-capability.ts @@ -33,3 +33,18 @@ export async function loadOpenTuiClientAppFactoryAtRuntime(): Promise Date: Sun, 21 Jun 2026 23:43:06 +0800 Subject: [PATCH 02/10] fix(commands): skip TUI path on Node.js runtime in step root command Co-Authored-By: Claude Opus 4.7 --- src/commands/root-command.test.ts | 77 +++++++++++++++++++++++++++++++ src/commands/root-command.ts | 52 +++++++++++++++++---- 2 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/commands/root-command.test.ts diff --git a/src/commands/root-command.test.ts b/src/commands/root-command.test.ts new file mode 100644 index 0000000..dd06863 --- /dev/null +++ b/src/commands/root-command.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { shouldUseTui } from "./root-command.js"; + +const originalBunVersion = process.versions.bun; + +beforeEach(() => { + delete (process.versions as { bun?: string }).bun; +}); + +afterEach(() => { + if (originalBunVersion === undefined) { + delete (process.versions as { bun?: string }).bun; + } else { + (process.versions as { bun?: string }).bun = originalBunVersion; + } +}); + +describe("shouldUseTui", () => { + it("returns false on Node.js even when all other conditions hold", () => { + const result = shouldUseTui({ + options: {}, + prompt: undefined, + attachments: undefined, + stdinIsTTY: true, + stdoutIsTTY: true, + }); + expect(result).toBe(false); + }); + + it("returns true on Bun when stdin/stdout are TTY and no prompt/attachments/json", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + const result = shouldUseTui({ + options: {}, + prompt: undefined, + attachments: undefined, + stdinIsTTY: true, + stdoutIsTTY: true, + }); + expect(result).toBe(true); + }); + + it("returns false when --json is passed (even on Bun)", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + const result = shouldUseTui({ + options: { json: true }, + prompt: undefined, + attachments: undefined, + stdinIsTTY: true, + stdoutIsTTY: true, + }); + expect(result).toBe(false); + }); + + it("returns false when a prompt is provided (one-shot mode)", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + const result = shouldUseTui({ + options: {}, + prompt: "summarize src/index.ts", + attachments: undefined, + stdinIsTTY: true, + stdoutIsTTY: true, + }); + expect(result).toBe(false); + }); + + it("returns false when stdin is not a TTY (piped input)", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + const result = shouldUseTui({ + options: {}, + prompt: undefined, + attachments: undefined, + stdinIsTTY: false, + stdoutIsTTY: true, + }); + expect(result).toBe(false); + }); +}); diff --git a/src/commands/root-command.ts b/src/commands/root-command.ts index 6abc139..099a57b 100644 --- a/src/commands/root-command.ts +++ b/src/commands/root-command.ts @@ -14,6 +14,7 @@ import { resolveStepCliRuntimeConfig } from "../runtime/runtime-config.js"; import { createLocalCliClientApp } from "../runtime/local-cli-app.js"; import { isOpenTuiEnabledInCurrentBuild, + isOpenTuiRuntimeSupported, loadOpenTuiClientAppFactoryAtRuntime, } from "../runtime/open-tui-capability.js"; @@ -22,6 +23,31 @@ import { const OPEN_TUI_COMPILE_TIME_ENABLED = process.env.STEP_CLI_ENABLE_OPENTUI !== "0"; +export interface ShouldUseTuiInputs { + options: { json?: boolean }; + prompt: string | undefined; + attachments: ReadonlyArray | undefined; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; +} + +/** + * Decide whether the root command should launch the OpenTUI TUI. Pure + * function so it can be unit-tested without spawning commander. + */ +export function shouldUseTui(inputs: ShouldUseTuiInputs): boolean { + return ( + OPEN_TUI_COMPILE_TIME_ENABLED && + isOpenTuiEnabledInCurrentBuild() && + isOpenTuiRuntimeSupported() && + !inputs.options.json && + (inputs.prompt?.trim().length ?? 0) === 0 && + (inputs.attachments?.length ?? 0) === 0 && + inputs.stdinIsTTY === true && + inputs.stdoutIsTTY === true + ); +} + export async function runRootCommand(argv: string[]): Promise { const program = configureCommanderProgram(new Command()); @@ -76,22 +102,30 @@ export async function runRootCommand(argv: string[]): Promise { }); const cliOptionSources = readSharedRuntimeCliOptionSources(actionCommand); - const shouldUseTui = + const shouldUseTuiResult = shouldUseTui({ + options, + prompt, + attachments, + stdinIsTTY: process.stdin.isTTY === true, + stdoutIsTTY: process.stdout.isTTY === true, + }); + const runtimeUnsupported = OPEN_TUI_COMPILE_TIME_ENABLED && isOpenTuiEnabledInCurrentBuild() && - !options.json && - (prompt?.trim().length ?? 0) === 0 && - (attachments?.length ?? 0) === 0 && - process.stdin.isTTY === true && - process.stdout.isTTY === true; + !isOpenTuiRuntimeSupported(); + if (runtimeUnsupported && process.stderr.isTTY) { + process.stderr.write( + "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n", + ); + } const { stepCliConfig } = await resolveStepCliRuntimeConfig({ options, cliOptionSources, resumeSession: Boolean(options.resume), - useAlternateScreen: shouldUseTui ? options.altScreen : false, - interactionSurface: shouldUseTui ? "interactive" : undefined, + useAlternateScreen: shouldUseTuiResult ? options.altScreen : false, + interactionSurface: shouldUseTuiResult ? "interactive" : undefined, }); - if (shouldUseTui) { + if (shouldUseTuiResult) { const createLocalTuiClientApp = await loadOpenTuiClientAppFactoryAtRuntime(); const app = await createLocalTuiClientApp(stepCliConfig); From 2261bb93308eb026ce00efae6e15ad8cb46beb32 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sun, 21 Jun 2026 23:53:03 +0800 Subject: [PATCH 03/10] fix(commands): skip TUI path on Node.js runtime in step resume --- src/commands/resume-command.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/commands/resume-command.ts b/src/commands/resume-command.ts index e860ffb..08396c9 100644 --- a/src/commands/resume-command.ts +++ b/src/commands/resume-command.ts @@ -12,6 +12,7 @@ import { resolveStepCliRuntimeConfig } from "../runtime/runtime-config.js"; import { createLocalCliClientApp } from "../runtime/local-cli-app.js"; import { isOpenTuiEnabledInCurrentBuild, + isOpenTuiRuntimeSupported, loadOpenTuiClientAppFactoryAtRuntime, } from "../runtime/open-tui-capability.js"; @@ -45,9 +46,19 @@ export async function runResumeCommand(argv: string[]): Promise { const shouldUseTui = OPEN_TUI_COMPILE_TIME_ENABLED && isOpenTuiEnabledInCurrentBuild() && + isOpenTuiRuntimeSupported() && !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true; + const runtimeUnsupported = + OPEN_TUI_COMPILE_TIME_ENABLED && + isOpenTuiEnabledInCurrentBuild() && + !isOpenTuiRuntimeSupported(); + if (runtimeUnsupported && process.stderr.isTTY) { + process.stderr.write( + "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n", + ); + } const { stepCliConfig } = await resolveStepCliRuntimeConfig({ options, cliOptionSources, From e9cfa74fc15605b1213b0e78ac957c4b9442768e Mon Sep 17 00:00:00 2001 From: knqiufan Date: Mon, 22 Jun 2026 00:00:25 +0800 Subject: [PATCH 04/10] fix(commands): fail step voice with actionable error on Node runtime --- src/commands/voice-command.test.ts | 32 ++++++++++++++++++++++++++++++ src/commands/voice-command.ts | 21 +++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/commands/voice-command.test.ts diff --git a/src/commands/voice-command.test.ts b/src/commands/voice-command.test.ts new file mode 100644 index 0000000..b8dce0c --- /dev/null +++ b/src/commands/voice-command.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getVoiceRuntimeError } from "./voice-command.js"; + +const originalBunVersion = process.versions.bun; + +beforeEach(() => { + delete (process.versions as { bun?: string }).bun; +}); + +afterEach(() => { + if (originalBunVersion === undefined) { + delete (process.versions as { bun?: string }).bun; + } else { + (process.versions as { bun?: string }).bun = originalBunVersion; + } +}); + +describe("getVoiceRuntimeError", () => { + it("returns null on Bun runtime", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + expect(getVoiceRuntimeError()).toBeNull(); + }); + + it("returns a non-empty error string on Node runtime", () => { + expect(getVoiceRuntimeError()).not.toBeNull(); + expect(getVoiceRuntimeError()).toMatch(/Bun runtime/); + }); + + it("error message mentions STEP_BUN_BIN for actionable recovery", () => { + expect(getVoiceRuntimeError()).toMatch(/STEP_BUN_BIN/); + }); +}); diff --git a/src/commands/voice-command.ts b/src/commands/voice-command.ts index f0cd402..ba31039 100644 --- a/src/commands/voice-command.ts +++ b/src/commands/voice-command.ts @@ -9,7 +9,10 @@ import { type SharedRuntimeCliOptions, } from "./shared-runtime-options.js"; import { resolveStepCliRuntimeConfig } from "../runtime/runtime-config.js"; -import { loadOpenTuiClientAppFactoryAtRuntime } from "../runtime/open-tui-capability.js"; +import { + isOpenTuiRuntimeSupported, + loadOpenTuiClientAppFactoryAtRuntime, +} from "../runtime/open-tui-capability.js"; import type { VoiceBootstrapConfig } from "../runtime/voice-bootstrap-config.js"; import { loadVoiceConfigFile, @@ -186,6 +189,11 @@ default.`, }, }; + const voiceRuntimeError = getVoiceRuntimeError(); + if (voiceRuntimeError !== null) { + throw new Error(voiceRuntimeError); + } + const createLocalTuiClientApp = await loadOpenTuiClientAppFactoryAtRuntime(); const app = await createLocalTuiClientApp(stepCliConfig, voice); @@ -199,6 +207,17 @@ default.`, await parseCommanderProgram(voiceProgram, ["node", "step voice", ...argv]); } +/** + * Returns null when voice mode may proceed, or an error message string when + * the runtime cannot support OpenTUI. Pure function for unit testing. + */ +export function getVoiceRuntimeError(): string | null { + if (isOpenTuiRuntimeSupported()) { + return null; + } + return "step voice requires Bun runtime on this platform. Install Bun via `winget install Oven-sh.Bun` or set STEP_BUN_BIN, then re-run."; +} + function normalizeInputMode( value: string | undefined, ): VoiceBootstrapConfig["inputMode"] { From 3b983ceeda3c4cbf1cd2ecc3b9a0303d462955fa Mon Sep 17 00:00:00 2001 From: knqiufan Date: Mon, 22 Jun 2026 00:06:51 +0800 Subject: [PATCH 05/10] fix(commands): make voice runtime error platform-aware Co-Authored-By: Claude Opus 4.7 --- src/commands/voice-command.test.ts | 7 +++++++ src/commands/voice-command.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/voice-command.test.ts b/src/commands/voice-command.test.ts index b8dce0c..391806a 100644 --- a/src/commands/voice-command.test.ts +++ b/src/commands/voice-command.test.ts @@ -29,4 +29,11 @@ describe("getVoiceRuntimeError", () => { it("error message mentions STEP_BUN_BIN for actionable recovery", () => { expect(getVoiceRuntimeError()).toMatch(/STEP_BUN_BIN/); }); + + it("error message includes install instructions", () => { + const message = getVoiceRuntimeError(); + expect(message).not.toBeNull(); + // Either winget (Windows) or bun.sh URL (other platforms). + expect(message).toMatch(/(winget|bun\.sh)/); + }); }); diff --git a/src/commands/voice-command.ts b/src/commands/voice-command.ts index ba31039..785faa1 100644 --- a/src/commands/voice-command.ts +++ b/src/commands/voice-command.ts @@ -215,7 +215,11 @@ export function getVoiceRuntimeError(): string | null { if (isOpenTuiRuntimeSupported()) { return null; } - return "step voice requires Bun runtime on this platform. Install Bun via `winget install Oven-sh.Bun` or set STEP_BUN_BIN, then re-run."; + const installHint = + process.platform === "win32" + ? "Install Bun via `winget install Oven-sh.Bun`" + : "Install Bun from https://bun.sh"; + return `step voice requires Bun runtime on this platform. ${installHint}, or set STEP_BUN_BIN, then re-run.`; } function normalizeInputMode( From a46dc3a8ed7bbd52d661c92cc53107cfd055f47b Mon Sep 17 00:00:00 2001 From: knqiufan Date: Mon, 22 Jun 2026 00:11:31 +0800 Subject: [PATCH 06/10] feat(setup): install Bun on Windows and prefer it in step.cmd launcher Co-Authored-By: Claude Opus 4.7 --- scripts/setup.ps1 | 124 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 853408f..036faa4 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -1,6 +1,7 @@ param( [switch]$SkipInstall, [switch]$SkipBuild, + [switch]$SkipBun, [switch]$ForceConfig, [switch]$Uninstall, [switch]$Overseas, @@ -104,6 +105,89 @@ function Find-Chrome { return $null } +function Resolve-BunBin { + if ($env:STEP_BUN_BIN -and (Test-Path $env:STEP_BUN_BIN)) { + return $env:STEP_BUN_BIN + } + $cmd = Get-Command bun -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + return $null +} + +function Install-Bun { + <# + .SYNOPSIS + Ensures Bun is available on PATH. Strategy: + 1. Already on PATH -> use it + 2. winget -> install Oven-sh.Bun + 3. Direct download -> github.com/oven-sh/bun/releases/latest + Returns the path to bun.exe, or $null if all attempts failed. + #> + $existing = Resolve-BunBin + if ($existing) { + Write-Ok "Bun already available: $existing" + return $existing + } + + Write-Section "[bun] Installing Bun (needed for OpenTUI TUI on Windows)" + + if (Test-Command "winget") { + Write-Info "Trying winget install Oven-sh.Bun ..." + & winget install Oven-sh.Bun --accept-source-agreements --accept-package-agreements --silent + if ($LASTEXITCODE -eq 0) { + $bunPath = Join-Path $HOME ".bun\bin\bun.exe" + $env:Path = "$HOME\.bun\bin;$env:Path" + $cmd = Get-Command bun -ErrorAction SilentlyContinue + if ($cmd) { + Write-Ok "Bun installed via winget: $($cmd.Source)" + return $cmd.Source + } + if (Test-Path $bunPath) { + Write-Ok "Bun installed via winget: $bunPath" + return $bunPath + } + } + Write-Warn "winget install exited $LASTEXITCODE; falling back to direct download" + } + + $installDir = Join-Path $HOME ".bun" + $bunExe = Join-Path $installDir "bin\bun.exe" + if (Test-Path $bunExe) { + Write-Ok "Bun already downloaded at $bunExe" + return $bunExe + } + + Write-Info "Downloading Bun for Windows x64 from github.com/oven-sh/bun ..." + $zipUrl = "https://github.com/oven-sh/bun/releases/latest/download/bun-windows-x64.zip" + $tmpZip = Join-Path $env:TEMP "bun-windows-x64.zip" + try { + Invoke-WebRequest -Uri $zipUrl -OutFile $tmpZip -UseBasicParsing + Expand-Archive -Path $tmpZip -DestinationPath $installDir -Force + # The zip extracts as `bun-windows-x64\bun.exe`; flatten into bin\ + $nested = Join-Path $installDir "bun-windows-x64\bun.exe" + if (Test-Path $nested) { + New-Item -ItemType Directory -Force (Join-Path $installDir "bin") | Out-Null + Move-Item -Force $nested $bunExe + Remove-Item -Recurse -Force (Join-Path $installDir "bun-windows-x64") -ErrorAction SilentlyContinue + } + Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue + if (Test-Path $bunExe) { + Add-UserPath (Join-Path $installDir "bin") + Write-Ok "Bun installed via direct download: $bunExe" + return $bunExe + } + } catch { + Write-Warn "Direct download failed: $($_.Exception.Message)" + } + + Write-Err "Could not install Bun. OpenTUI TUI will not work." + Write-Info "Manual install: download from https://bun.com/docs/installation/windows" + Write-Info "Or re-run this script with -SkipBun to install with Node only (text CLI fallback)." + return $null +} + Set-Location (Join-Path $PSScriptRoot "..") $RepoRoot = (Get-Location).Path $InstallDir = Join-Path $HOME ".step-cli\bin" @@ -115,11 +199,12 @@ if ($Uninstall) { Remove-UserPath $InstallDir Write-Ok "Removed $InstallDir and its user PATH entry" Write-Info "Config and session history under $HOME\.step-cli are preserved." + Write-Info "Bun installation (under $HOME\.bun) is preserved; uninstall via winget or by deleting the directory." exit 0 } if (-not (Test-Command "pnpm")) { - Write-Section "[0/7] Bootstrapping pnpm via corepack" + Write-Section "[0/8] Bootstrapping pnpm via corepack" if (-not (Test-Command "corepack")) { Write-Err "Neither pnpm nor corepack was found in PATH." Write-Info "Install Node.js 20+ and then re-run this script." @@ -135,7 +220,7 @@ if (-not (Test-Command "pnpm")) { Write-Ok "pnpm $(pnpm --version) ready" } -Write-Section "[1/7] Installing workspace dependencies" +Write-Section "[1/8] Installing workspace dependencies" if ($SkipInstall) { Write-Info "Skipped (-SkipInstall)" } else { @@ -143,7 +228,22 @@ if ($SkipInstall) { Write-Ok "Workspace dependencies installed" } -Write-Section "[2/7] Initializing user config" +$BunBin = $null +if ($SkipBun) { + Write-Section "[2/8] Bun (skipped via -SkipBun)" + Write-Warn "OpenTUI TUI will be unavailable; step will fall back to text CLI." +} else { + Write-Section "[2/8] Bun runtime (for OpenTUI TUI)" + $BunBin = Install-Bun + if ($BunBin) { + $env:STEP_BUN_BIN = $BunBin + Write-Ok "STEP_BUN_BIN set to $BunBin" + } else { + Write-Warn "Proceeding without Bun. OpenTUI TUI will use Node fallback." + } +} + +Write-Section "[3/8] Initializing user config" if ((Test-Path $UserConfig) -and (-not $ForceConfig)) { Write-Ok "Config already exists at $UserConfig (use -ForceConfig to overwrite)" } else { @@ -155,7 +255,7 @@ if ((Test-Path $UserConfig) -and (-not $ForceConfig)) { Write-Ok "Config written to $UserConfig" } -Write-Section "[3/7] Silero VAD" +Write-Section "[4/8] Silero VAD" pnpm setup:silero if ($LASTEXITCODE -ne 0) { throw "setup:silero failed with exit code $LASTEXITCODE" @@ -164,7 +264,7 @@ Invoke-StepCli vad set silero Invoke-StepCli vad status Write-Ok "Silero enabled (voice.defaults.vad = silero)" -Write-Section "[4/7] Browser audio / AEC" +Write-Section "[5/8] Browser audio / AEC" $Chrome = Find-Chrome if ($Chrome) { Write-Ok "Chrome/Chromium found: $Chrome" @@ -196,7 +296,7 @@ if ($Overseas) { Write-Ok "Patched $UserConfig for api.stepfun.ai" } -Write-Section "[5/7] Building production bundle" +Write-Section "[6/8] Building production bundle" if ($SkipBuild) { Write-Warn "Skipped (-SkipBuild). Will reuse existing dist." } else { @@ -204,7 +304,7 @@ Write-Section "[5/7] Building production bundle" Write-Ok "dist built" } -Write-Section "[6/7] Preparing Windows launcher" +Write-Section "[7/8] Preparing Windows launcher" if ($SkipBuild) { if (-not (Test-Path "dist\index.js")) { Write-Err "No existing dist\index.js found; cannot continue with -SkipBuild." @@ -215,7 +315,7 @@ if ($SkipBuild) { Write-Info "Using a Node-based step.cmd launcher; Bun native compilation is not required on Windows." } -Write-Section "[7/7] Installing to $InstallDir" +Write-Section "[8/8] Installing to $InstallDir" New-Item -ItemType Directory -Force $InstallDir | Out-Null foreach ($dir in @("package.json", "bin", "dist", "packages", "extensions", "skills", "node_modules")) { @@ -233,6 +333,14 @@ $Launcher = Join-Path $InstallDir "step.cmd" $LauncherContent = @" @echo off setlocal +if defined STEP_BUN_BIN ( + "%STEP_BUN_BIN%" "%~dp0bin\step-cli.js" %* + exit /b %ERRORLEVEL% +) +where bun >nul 2>&1 && ( + bun "%~dp0bin\step-cli.js" %* + exit /b %ERRORLEVEL% +) node "%~dp0bin\step-cli.js" %* exit /b %ERRORLEVEL% "@ From 0719328ad38764cd199a40675d56afe98e7cb028 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Mon, 22 Jun 2026 00:17:35 +0800 Subject: [PATCH 07/10] fix(setup): harden Install-Bun against partial failures and stale PATH --- scripts/setup.ps1 | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 036faa4..99ad693 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -138,15 +138,18 @@ function Install-Bun { & winget install Oven-sh.Bun --accept-source-agreements --accept-package-agreements --silent if ($LASTEXITCODE -eq 0) { $bunPath = Join-Path $HOME ".bun\bin\bun.exe" - $env:Path = "$HOME\.bun\bin;$env:Path" - $cmd = Get-Command bun -ErrorAction SilentlyContinue - if ($cmd) { - Write-Ok "Bun installed via winget: $($cmd.Source)" - return $cmd.Source + if ($env:Path -notlike "*$HOME\.bun\bin*") { + $env:Path = "$HOME\.bun\bin;$env:Path" } - if (Test-Path $bunPath) { - Write-Ok "Bun installed via winget: $bunPath" - return $bunPath + $cmd = Get-Command bun -ErrorAction SilentlyContinue + $candidate = if ($cmd) { $cmd.Source } elseif (Test-Path $bunPath) { $bunPath } else { $null } + if ($candidate) { + $versionOutput = & $candidate --version 2>$null + if ($LASTEXITCODE -eq 0 -and $versionOutput) { + Write-Ok "Bun installed via winget: $candidate ($($versionOutput.Trim()))" + return $candidate + } + Write-Warn "Bun at $candidate did not respond to --version; continuing to next strategy" } } Write-Warn "winget install exited $LASTEXITCODE; falling back to direct download" @@ -172,14 +175,19 @@ function Install-Bun { Move-Item -Force $nested $bunExe Remove-Item -Recurse -Force (Join-Path $installDir "bun-windows-x64") -ErrorAction SilentlyContinue } - Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue if (Test-Path $bunExe) { - Add-UserPath (Join-Path $installDir "bin") - Write-Ok "Bun installed via direct download: $bunExe" - return $bunExe + $versionOutput = & $bunExe --version 2>$null + if ($LASTEXITCODE -eq 0 -and $versionOutput) { + Add-UserPath (Join-Path $installDir "bin") + Write-Ok "Bun installed via direct download: $bunExe ($($versionOutput.Trim()))" + return $bunExe + } + Write-Warn "Downloaded Bun at $bunExe did not respond to --version; install incomplete" } } catch { Write-Warn "Direct download failed: $($_.Exception.Message)" + } finally { + Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue } Write-Err "Could not install Bun. OpenTUI TUI will not work." From da9ad3ef4bb35c3ec4ade2170a191628d5958957 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Mon, 22 Jun 2026 00:47:09 +0800 Subject: [PATCH 08/10] fix(setup): don't set STEP_BUN_BIN to WinGet app execution alias shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winget installs Bun as a zero-byte reparse point at %LOCALAPPDATA%\Microsoft\WinGet\Links\bun.exe. PowerShell and cmd.exe resolve it via shell APIs, but Node's child_process.spawn uses CreateProcess directly and gets ENOENT — breaking Invoke-StepCli which spawns via node scripts/run-step.mjs. When the resolved Bun path is a WinGet shim, leave STEP_BUN_BIN unset. Node spawns then fall back to tsx (existing behavior), and the step.cmd launcher resolves Bun via 'where bun' (cmd handles the alias correctly). Windows CI was the first to hit this because it runs setup.ps1 without -SkipBun; real Windows users would hit it too. --- scripts/setup.ps1 | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 99ad693..225054d 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -244,8 +244,21 @@ if ($SkipBun) { Write-Section "[2/8] Bun runtime (for OpenTUI TUI)" $BunBin = Install-Bun if ($BunBin) { - $env:STEP_BUN_BIN = $BunBin - Write-Ok "STEP_BUN_BIN set to $BunBin" + # WinGet installs Bun as an "app execution alias" — a zero-byte reparse + # point under %LOCALAPPDATA%\Microsoft\WinGet\Links\bun.exe. PowerShell + # and cmd.exe resolve it via shell APIs, but Node's child_process.spawn + # uses CreateProcess directly and gets ENOENT. Setting STEP_BUN_BIN to + # such a shim breaks Invoke-StepCli (which spawns via Node). When we + # detect a shim, leave STEP_BUN_BIN unset: Node spawns fall back to tsx + # and the installed step.cmd launcher resolves Bun via `where bun`. + if ($BunBin -match "WinGet\\Links\\") { + Write-Ok "Bun available at $BunBin (WinGet app execution alias)" + Write-Info "STEP_BUN_BIN not set — Node spawn cannot use WinGet aliases directly." + Write-Info "step.cmd launcher will resolve Bun via PATH (cmd handles aliases)." + } else { + $env:STEP_BUN_BIN = $BunBin + Write-Ok "STEP_BUN_BIN set to $BunBin" + } } else { Write-Warn "Proceeding without Bun. OpenTUI TUI will use Node fallback." } From dcec21781d61b2f1feb7d116625695a4f6a68a09 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 18 Jul 2026 01:28:23 +0800 Subject: [PATCH 09/10] fix(commands): scope Bun fallback warning to TUI requests --- src/commands/resume-command.ts | 16 ++++------- src/commands/root-command.ts | 21 ++++++-------- src/runtime/open-tui-capability.test.ts | 37 +++++++++++++++++++++++++ src/runtime/open-tui-capability.ts | 25 +++++++++++++++++ 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/commands/resume-command.ts b/src/commands/resume-command.ts index 08396c9..81e78bf 100644 --- a/src/commands/resume-command.ts +++ b/src/commands/resume-command.ts @@ -14,6 +14,7 @@ import { isOpenTuiEnabledInCurrentBuild, isOpenTuiRuntimeSupported, loadOpenTuiClientAppFactoryAtRuntime, + warnWhenOpenTuiRuntimeUnsupported, } from "../runtime/open-tui-capability.js"; // Keep this build flag in the command module so rolldown can fold the bundle's @@ -43,22 +44,15 @@ export async function runResumeCommand(argv: string[]): Promise { actionCommand: Command, ) => { const cliOptionSources = readSharedRuntimeCliOptionSources(actionCommand); - const shouldUseTui = + const isTuiOtherwiseEligible = OPEN_TUI_COMPILE_TIME_ENABLED && isOpenTuiEnabledInCurrentBuild() && - isOpenTuiRuntimeSupported() && !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true; - const runtimeUnsupported = - OPEN_TUI_COMPILE_TIME_ENABLED && - isOpenTuiEnabledInCurrentBuild() && - !isOpenTuiRuntimeSupported(); - if (runtimeUnsupported && process.stderr.isTTY) { - process.stderr.write( - "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n", - ); - } + const shouldUseTui = + isTuiOtherwiseEligible && isOpenTuiRuntimeSupported(); + warnWhenOpenTuiRuntimeUnsupported(isTuiOtherwiseEligible); const { stepCliConfig } = await resolveStepCliRuntimeConfig({ options, cliOptionSources, diff --git a/src/commands/root-command.ts b/src/commands/root-command.ts index 099a57b..812a6aa 100644 --- a/src/commands/root-command.ts +++ b/src/commands/root-command.ts @@ -16,6 +16,7 @@ import { isOpenTuiEnabledInCurrentBuild, isOpenTuiRuntimeSupported, loadOpenTuiClientAppFactoryAtRuntime, + warnWhenOpenTuiRuntimeUnsupported, } from "../runtime/open-tui-capability.js"; // Keep this build flag in the command module so rolldown can fold the bundle's @@ -36,10 +37,13 @@ export interface ShouldUseTuiInputs { * function so it can be unit-tested without spawning commander. */ export function shouldUseTui(inputs: ShouldUseTuiInputs): boolean { + return isTuiOtherwiseEligible(inputs) && isOpenTuiRuntimeSupported(); +} + +function isTuiOtherwiseEligible(inputs: ShouldUseTuiInputs): boolean { return ( OPEN_TUI_COMPILE_TIME_ENABLED && isOpenTuiEnabledInCurrentBuild() && - isOpenTuiRuntimeSupported() && !inputs.options.json && (inputs.prompt?.trim().length ?? 0) === 0 && (inputs.attachments?.length ?? 0) === 0 && @@ -102,22 +106,15 @@ export async function runRootCommand(argv: string[]): Promise { }); const cliOptionSources = readSharedRuntimeCliOptionSources(actionCommand); - const shouldUseTuiResult = shouldUseTui({ + const tuiInputs = { options, prompt, attachments, stdinIsTTY: process.stdin.isTTY === true, stdoutIsTTY: process.stdout.isTTY === true, - }); - const runtimeUnsupported = - OPEN_TUI_COMPILE_TIME_ENABLED && - isOpenTuiEnabledInCurrentBuild() && - !isOpenTuiRuntimeSupported(); - if (runtimeUnsupported && process.stderr.isTTY) { - process.stderr.write( - "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n", - ); - } + } satisfies ShouldUseTuiInputs; + const shouldUseTuiResult = shouldUseTui(tuiInputs); + warnWhenOpenTuiRuntimeUnsupported(isTuiOtherwiseEligible(tuiInputs)); const { stepCliConfig } = await resolveStepCliRuntimeConfig({ options, cliOptionSources, diff --git a/src/runtime/open-tui-capability.test.ts b/src/runtime/open-tui-capability.test.ts index b3bc6d2..76e9081 100644 --- a/src/runtime/open-tui-capability.test.ts +++ b/src/runtime/open-tui-capability.test.ts @@ -3,6 +3,7 @@ import { isOpenTuiEnabledInCurrentBuild, isOpenTuiRuntimeSupported, parseOpenTuiEnabledValue, + warnWhenOpenTuiRuntimeUnsupported, } from "./open-tui-capability.js"; const originalBunVersion = process.versions.bun; @@ -58,3 +59,39 @@ describe("isOpenTuiRuntimeSupported", () => { expect(isOpenTuiRuntimeSupported()).toBe(false); }); }); + +describe("warnWhenOpenTuiRuntimeUnsupported", () => { + it("warns only when the runtime is the sole blocker for an interactive TUI", () => { + delete (process.versions as { bun?: string }).bun; + const messages: string[] = []; + const stderr = { + isTTY: true, + write: (message: string) => { + messages.push(message); + return true; + }, + }; + + warnWhenOpenTuiRuntimeUnsupported(true, stderr); + warnWhenOpenTuiRuntimeUnsupported(false, stderr); + + expect(messages).toEqual([ + "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n", + ]); + }); + + it("does not warn when Bun supports the TUI runtime", () => { + (process.versions as { bun?: string }).bun = "1.1.0"; + const messages: string[] = []; + + warnWhenOpenTuiRuntimeUnsupported(true, { + isTTY: true, + write: (message: string) => { + messages.push(message); + return true; + }, + }); + + expect(messages).toEqual([]); + }); +}); diff --git a/src/runtime/open-tui-capability.ts b/src/runtime/open-tui-capability.ts index 79a557a..59439af 100644 --- a/src/runtime/open-tui-capability.ts +++ b/src/runtime/open-tui-capability.ts @@ -48,3 +48,28 @@ export function isOpenTuiRuntimeSupported(): boolean { typeof process.versions.bun === "string" && process.versions.bun !== "" ); } + +export interface OpenTuiRuntimeWarningWriter { + readonly isTTY?: boolean; + write(message: string): boolean; +} + +const OPEN_TUI_RUNTIME_UNSUPPORTED_WARNING = + "warning: OpenTUI TUI requires Bun runtime; falling back to text CLI. Install Bun or use a Bun-based launcher.\n"; + +/** + * Warn only when the TUI would otherwise have been selected. This keeps + * one-shot, JSON, and piped commands quiet when they never request a TUI. + */ +export function warnWhenOpenTuiRuntimeUnsupported( + isTuiOtherwiseEligible: boolean, + stderr: OpenTuiRuntimeWarningWriter = process.stderr, +): void { + if ( + isTuiOtherwiseEligible && + !isOpenTuiRuntimeSupported() && + stderr.isTTY === true + ) { + stderr.write(OPEN_TUI_RUNTIME_UNSUPPORTED_WARNING); + } +} From 753ccffa9f8eadfa683c40507a691271dff3c2c1 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 18 Jul 2026 01:45:07 +0800 Subject: [PATCH 10/10] fix(setup): resolve launcher imports from install root --- scripts/setup.ps1 | 10 +++++++--- scripts/setup.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 225054d..f3ca6e5 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -354,16 +354,20 @@ $Launcher = Join-Path $InstallDir "step.cmd" $LauncherContent = @" @echo off setlocal +pushd "%~dp0" || exit /b 1 if defined STEP_BUN_BIN ( "%STEP_BUN_BIN%" "%~dp0bin\step-cli.js" %* - exit /b %ERRORLEVEL% + goto :exit ) where bun >nul 2>&1 && ( bun "%~dp0bin\step-cli.js" %* - exit /b %ERRORLEVEL% + goto :exit ) node "%~dp0bin\step-cli.js" %* -exit /b %ERRORLEVEL% +:exit +set "STEP_EXIT_CODE=%ERRORLEVEL%" +popd +exit /b %STEP_EXIT_CODE% "@ Set-Content -Encoding ASCII -Path $Launcher -Value $LauncherContent Write-Ok "Installed launcher: $Launcher" diff --git a/scripts/setup.test.ts b/scripts/setup.test.ts index c59b701..3862173 100644 --- a/scripts/setup.test.ts +++ b/scripts/setup.test.ts @@ -117,3 +117,16 @@ describe("scripts/setup.sh", () => { }, ); }); + +describe("scripts/setup.ps1", () => { + it("runs the launcher from its install directory before resolving runtime imports", () => { + const script = readFileSync( + join(process.cwd(), "scripts", "setup.ps1"), + "utf8", + ); + + expect(script).toContain('pushd "%~dp0" || exit /b 1'); + expect(script).toContain('set "STEP_EXIT_CODE=%ERRORLEVEL%"'); + expect(script).toContain("popd"); + }); +});