diff --git a/packages/cli/package.json b/packages/cli/package.json index 2fde4db11..12e4d3b92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.7", + "version": "0.26.9", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts index 2f2bda5aa..867ee71ae 100644 --- a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts +++ b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts @@ -113,11 +113,13 @@ describe("cmdRun happy-path pipeline", () => { let consoleMocks: ReturnType; let originalFetch: typeof global.fetch; let processExitSpy: ReturnType; + let stderrSpy: ReturnType; let historyDir: string; let originalSpawnHome: string | undefined; beforeEach(async () => { consoleMocks = createConsoleMocks(); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); mockLogError.mockClear(); mockLogInfo.mockClear(); mockLogStep.mockClear(); @@ -144,6 +146,7 @@ describe("cmdRun happy-path pipeline", () => { afterEach(() => { global.fetch = originalFetch; processExitSpy.mockRestore(); + stderrSpy.mockRestore(); restoreMocks(consoleMocks.log, consoleMocks.error); // Clean up history directory @@ -173,7 +176,7 @@ describe("cmdRun happy-path pipeline", () => { expect(scriptFetches[0].url).toContain("openrouter.ai"); }); - it("should show spinner start and stop for successful download", async () => { + it("should log download start and completion messages for successful download", async () => { global.fetch = mockFetchForDownload({ primaryOk: true, }); @@ -181,11 +184,9 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const startCalls = mockSpinnerStart.mock.calls.map((c: unknown[]) => c[0]); - expect(startCalls.some((msg: string) => msg.includes("Downloading"))).toBe(true); - - const stopCalls = mockSpinnerStop.mock.calls.map((c: unknown[]) => c[0]); - expect(stopCalls.some((msg: string) => isString(msg) && msg.includes("downloaded"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("Downloading"); + expect(stderrOutput).toContain("downloaded"); }); it("should not call process.exit on successful execution", async () => { @@ -220,7 +221,7 @@ describe("cmdRun happy-path pipeline", () => { expect(scriptFetches[1].url).toContain("raw.githubusercontent.com"); }); - it("should show fallback spinner message when primary fails", async () => { + it("should log fallback step message when primary fails", async () => { global.fetch = mockFetchForDownload({ primaryOk: false, primaryStatus: 502, @@ -230,11 +231,11 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const messageCalls = mockSpinnerMessage.mock.calls.map((c: unknown[]) => c[0]); - expect(messageCalls.some((msg: string) => msg.includes("fallback"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("fallback"); }); - it("should show 'fallback' in stop message when fallback succeeds", async () => { + it("should log 'fallback' in completion message when fallback succeeds", async () => { global.fetch = mockFetchForDownload({ primaryOk: false, primaryStatus: 403, @@ -244,8 +245,8 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const stopCalls = mockSpinnerStop.mock.calls.map((c: unknown[]) => c[0]); - expect(stopCalls.some((msg: string) => isString(msg) && msg.includes("fallback"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("fallback"); }); }); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 8d2540703..3146158e7 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -204,7 +204,9 @@ export async function confirmAndDelete( await ensureDeleteCredentials(record); } - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(`Deleting ${label}...`); // Cloud destroy functions log progress to stderr (logStep/logInfo). diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index c43b166c2..95eba0f4e 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -247,7 +247,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise { - const s = p.spinner(); - s.start("Downloading spawn script..."); + logStep("Downloading spawn script..."); const r = await asyncTryCatch(async () => { const res = await fetch(primaryUrl, { @@ -212,26 +211,26 @@ async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: strin }); if (res.ok) { const text = await res.text(); - s.stop("Script downloaded"); + logInfo("Script downloaded"); return text; } // Fallback to GitHub raw - s.message("Trying fallback source..."); + logStep("Trying fallback source..."); const ghRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); if (!ghRes.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); reportDownloadFailure(res.status, ghRes.status); process.exit(1); } const text = await ghRes.text(); - s.stop("Script downloaded (fallback)"); + logInfo("Script downloaded (fallback)"); return text; }); if (!r.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); throw r.error; } return r.data; @@ -589,8 +588,7 @@ function runBashScript( */ async function downloadBundle(cloud: string): Promise { const bundleUrl = `https://github.com/${REPO}/releases/download/${cloud}-latest/${cloud}.js`; - const s = p.spinner(); - s.start("Downloading spawn bundle..."); + logStep("Downloading spawn bundle..."); const r = await asyncTryCatch(async () => { const res = await fetch(bundleUrl, { @@ -598,16 +596,16 @@ async function downloadBundle(cloud: string): Promise { redirect: "follow", }); if (!res.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); p.log.error(`Bundle not found at ${bundleUrl} (HTTP ${res.status})`); process.exit(2); } const text = await res.text(); - s.stop("Bundle downloaded"); + logInfo("Bundle downloaded"); return text; }); if (!r.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); throw r.error; } return r.data; diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 329899aff..f51783d22 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -31,7 +31,9 @@ export function handleCancel(): never { } async function withSpinner(msg: string, fn: () => Promise, doneMsg?: string): Promise { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(msg); const r = await asyncTryCatch(fn); s.stop(r.ok ? (doneMsg ?? msg.replace(/\.{3}$/, "")) : pc.red("Failed")); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 4b6e0aa5b..69af210af 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -304,7 +304,9 @@ export async function cmdStatus( const goneRecords = results.filter((r) => r.liveState === "gone").map((r) => r.record); if (opts.prune && goneRecords.length > 0) { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(`Pruning ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""}...`); for (const record of goneRecords) { markRecordDeleted(record); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index ba8e8a2a9..44467cca3 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -143,7 +143,9 @@ export interface UpdateOptions { } export async function cmdUpdate(options?: UpdateOptions): Promise { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start("Checking for updates..."); const r = await asyncTryCatch(() => fetchRemoteVersion()); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 7fe20cece..233466fc0 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -688,6 +688,13 @@ async function postInstall( logStep("Starting agent..."); + // Reset terminal state before handing off to the interactive SSH session. + // @clack/prompts may have left the cursor hidden or set ANSI attributes + // (e.g. color, bold) that would corrupt the remote agent's TUI rendering. + if (process.stderr.isTTY) { + process.stderr.write("\x1b[?25h\x1b[0m"); + } + prepareStdinForHandoff(); const sessionCmd = cloud.cloudName === "local" ? launchCmd : wrapWithRestartLoop(launchCmd); diff --git a/packages/cli/src/shared/ssh.ts b/packages/cli/src/shared/ssh.ts index 71fd5bbf2..1cf025735 100644 --- a/packages/cli/src/shared/ssh.ts +++ b/packages/cli/src/shared/ssh.ts @@ -135,6 +135,29 @@ export function spawnInteractive(args: string[], env?: Record + nodeSpawnSync( + "stty", + [ + "sane", + ], + { + stdio: "inherit", + }, + ), + ); + return result.status ?? 1; } diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index e68ee5999..0b3c4d481 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -1,6 +1,8 @@ // shared/ui.ts — Logging, prompts, and browser opening // @clack/prompts is bundled into cli.js at build time. +import "../unicode-detect.js"; // Must run before @clack/prompts: configures TERM for unicode detection + import { readFileSync } from "node:fs"; import * as p from "@clack/prompts"; import { isString } from "@openrouter/spawn-shared";