Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.26.7",
"version": "0.26.9",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
25 changes: 13 additions & 12 deletions packages/cli/src/__tests__/cmdrun-happy-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,13 @@ describe("cmdRun happy-path pipeline", () => {
let consoleMocks: ReturnType<typeof createConsoleMocks>;
let originalFetch: typeof global.fetch;
let processExitSpy: ReturnType<typeof spyOn>;
let stderrSpy: ReturnType<typeof spyOn>;
let historyDir: string;
let originalSpawnHome: string | undefined;

beforeEach(async () => {
consoleMocks = createConsoleMocks();
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
Expand All @@ -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
Expand Down Expand Up @@ -173,19 +176,17 @@ 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,
});
await loadManifest(true);

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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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");
});
});

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise<vo
}

// ── Check connectivity ─────────────────────────────────────────────────────
const connectSpinner = p.spinner();
const connectSpinner = p.spinner({
output: process.stderr,
});
connectSpinner.start(`Checking connectivity to ${pc.cyan(ip)}...`);

const reachable = await tcpCheckFn(ip, 22, 10000);
Expand All @@ -272,7 +274,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise<vo
const needsDetection = !detectedAgent || !detectedCloud;

if (needsDetection) {
const detectSpinner = p.spinner();
const detectSpinner = p.spinner({
output: process.stderr,
});
detectSpinner.start("Auto-detecting agent and cloud provider...");

if (!detectedAgent) {
Expand Down
24 changes: 11 additions & 13 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js";
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getLocalShell, isWindows } from "../shared/shell.js";
import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { logError, logInfo, logStep, prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
import { handleRecordAction } from "./list.js";
import {
Expand Down Expand Up @@ -203,35 +203,34 @@ export function showDryRunPreview(manifest: Manifest, agent: string, cloud: stri
// ── Script download ──────────────────────────────────────────────────────────

async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise<string> {
const s = p.spinner();
s.start("Downloading spawn script...");
logStep("Downloading spawn script...");

const r = await asyncTryCatch(async () => {
const res = await fetch(primaryUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
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;
Expand Down Expand Up @@ -589,25 +588,24 @@ function runBashScript(
*/
async function downloadBundle(cloud: string): Promise<string> {
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, {
signal: AbortSignal.timeout(FETCH_TIMEOUT),
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;
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export function handleCancel(): never {
}

async function withSpinner<T>(msg: string, fn: () => Promise<T>, doneMsg?: string): Promise<T> {
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"));
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ export interface UpdateOptions {
}

export async function cmdUpdate(options?: UpdateOptions): Promise<void> {
const s = p.spinner();
const s = p.spinner({
output: process.stderr,
});
s.start("Checking for updates...");

const r = await asyncTryCatch(() => fetchRemoteVersion());
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/shared/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/shared/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,29 @@ export function spawnInteractive(args: string[], env?: Record<string, string | u
stdio: "inherit",
env: env ?? process.env,
});

// Reset terminal state after the interactive session ends.
// The remote agent's TUI (e.g. Claude Code) may leave the terminal in
// raw mode or with altered attributes, causing garbled post-session output.
if (process.stderr.isTTY) {
process.stderr.write("\x1b[0m\x1b[?25h"); // reset attributes + show cursor
}
if (process.stdout.isTTY) {
process.stdout.write("\x1b[0m\x1b[?25h");
}
// Restore sane terminal settings (cooked mode, echo, etc.)
tryCatch(() =>
nodeSpawnSync(
"stty",
[
"sane",
],
{
stdio: "inherit",
},
),
);

return result.status ?? 1;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/shared/ui.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading