From 059690f8d7d96f2f76a880bae36930f4e8dab4b5 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:01:57 -0800 Subject: [PATCH] fix(ux): include cloud provider dashboard URLs in script failure and interrupt messages (#1029) When spawn scripts fail or are interrupted, error messages now include the cloud provider's actual dashboard URL instead of generic "check your cloud provider dashboard" text. This helps users quickly navigate to their provider to check server status, clean up orphaned resources, or debug provisioning failures. Agent: ux-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- cli/package.json | 2 +- cli/src/__tests__/exec-script-errors.test.ts | 2 +- .../__tests__/script-failure-guidance.test.ts | 79 +++++++++++++++++++ cli/src/commands.ts | 51 +++++++----- 4 files changed, 113 insertions(+), 21 deletions(-) diff --git a/cli/package.json b/cli/package.json index 2e4996e6..54a699b2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.79", + "version": "0.2.80", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/exec-script-errors.test.ts b/cli/src/__tests__/exec-script-errors.test.ts index 8f3e5231..b818b6b7 100644 --- a/cli/src/__tests__/exec-script-errors.test.ts +++ b/cli/src/__tests__/exec-script-errors.test.ts @@ -199,7 +199,7 @@ describe("execScript bash execution error handling", () => { const warnText = warnMessages.join("\n"); expect(warnText).toContain("interrupted"); expect(warnText).toContain("server"); - expect(warnText).toContain("cloud provider dashboard"); + expect(warnText).toContain("dashboard"); }); }); diff --git a/cli/src/__tests__/script-failure-guidance.test.ts b/cli/src/__tests__/script-failure-guidance.test.ts index 774d53dc..76e0a8a7 100644 --- a/cli/src/__tests__/script-failure-guidance.test.ts +++ b/cli/src/__tests__/script-failure-guidance.test.ts @@ -606,3 +606,82 @@ describe("buildRetryCommand", () => { expect(buildRetryCommand("aider", "vultr", "")).toBe("spawn aider vultr"); }); }); + +describe("dashboard URL in guidance", () => { + describe("getScriptFailureGuidance with dashboardUrl", () => { + it("should include dashboard URL for exit code 1 when provided", () => { + const lines = getScriptFailureGuidance(1, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://console.hetzner.cloud/"); + expect(joined).toContain("dashboard"); + }); + + it("should include dashboard URL for exit code 130 when provided", () => { + const lines = getScriptFailureGuidance(130, "sprite", undefined, "https://sprite.sh"); + const joined = lines.join("\n"); + expect(joined).toContain("https://sprite.sh"); + expect(joined).toContain("dashboard"); + }); + + it("should include dashboard URL for exit code 137 when provided", () => { + const lines = getScriptFailureGuidance(137, "vultr", undefined, "https://my.vultr.com/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://my.vultr.com/"); + }); + + it("should include dashboard URL for default exit code when provided", () => { + const lines = getScriptFailureGuidance(42, "digitalocean", undefined, "https://cloud.digitalocean.com/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://cloud.digitalocean.com/"); + }); + + it("should fall back to generic message when no dashboardUrl", () => { + const lines = getScriptFailureGuidance(130, "sprite"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + expect(joined).not.toContain("https://"); + }); + + it("should not add dashboard URL for exit codes 127, 126, 255, 2", () => { + for (const code of [127, 126, 255, 2]) { + const lines = getScriptFailureGuidance(code, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).not.toContain("https://console.hetzner.cloud/"); + } + }); + }); + + describe("getSignalGuidance with dashboardUrl", () => { + it("should include dashboard URL for SIGKILL when provided", () => { + const lines = getSignalGuidance("SIGKILL", "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://console.hetzner.cloud/"); + expect(joined).toContain("dashboard"); + }); + + it("should include dashboard URL for SIGTERM when provided", () => { + const lines = getSignalGuidance("SIGTERM", "https://my.vultr.com/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://my.vultr.com/"); + }); + + it("should include dashboard URL for SIGINT when provided", () => { + const lines = getSignalGuidance("SIGINT", "https://cloud.digitalocean.com/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://cloud.digitalocean.com/"); + }); + + it("should fall back to generic message when no dashboardUrl", () => { + const lines = getSignalGuidance("SIGKILL"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + expect(joined).not.toContain("https://"); + }); + + it("should not add dashboard URL for SIGHUP", () => { + const lines = getSignalGuidance("SIGHUP", "https://example.com"); + const joined = lines.join("\n"); + expect(joined).not.toContain("https://example.com"); + }); + }); +}); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 7977cd8e..32628abe 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -367,7 +367,7 @@ export async function cmdInteractive(): Promise { p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`); p.outro("Handing off to spawn script..."); - await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice)); + await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url); } // ── Run ──────────────────────────────────────────────────────────────────────── @@ -591,7 +591,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string, dryR const suffix = prompt ? " with prompt..." : "..."; p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`); - await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud)); + await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud), manifest.clouds[cloud].url); } export function getStatusDescription(status: number): string { @@ -695,7 +695,10 @@ export function credentialHints(cloud: string, authHint?: string, verb = "Missin return lines; } -export function getSignalGuidance(signal: string): string[] { +export function getSignalGuidance(signal: string, dashboardUrl?: string): string[] { + const dashboardHint = dashboardUrl + ? ` - Check your dashboard: ${pc.cyan(dashboardUrl)}` + : " - Check your cloud provider dashboard to stop or delete any unused servers"; switch (signal) { case "SIGKILL": return [ @@ -703,7 +706,7 @@ export function getSignalGuidance(signal: string): string[] { " - Out of memory (OOM killer terminated the process)", " - The server may not have enough RAM for this agent", " - Try a larger instance size or a different cloud provider", - " - Check your cloud provider dashboard to stop or delete any unused servers", + dashboardHint, ]; case "SIGTERM": return [ @@ -711,12 +714,13 @@ export function getSignalGuidance(signal: string): string[] { " - The process was stopped by the system or a supervisor", " - Server shutdown or reboot in progress", " - Cloud provider terminated the instance (spot/preemptible instance or billing issue)", + dashboardHint, ]; case "SIGINT": return [ "Script was interrupted (Ctrl+C).", "Note: If a server was already created, it may still be running.", - " Check your cloud provider dashboard to stop or delete any unused servers.", + dashboardHint, ]; case "SIGHUP": return [ @@ -729,25 +733,28 @@ export function getSignalGuidance(signal: string): string[] { return [ `Script was killed by signal ${signal}.`, " - The process was terminated by the system or another process", - " - Check your cloud provider dashboard for any orphaned servers", + dashboardHint, ]; } } -export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string): string[] { +export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string, dashboardUrl?: string): string[] { + const dashboardHint = dashboardUrl + ? ` - Check your dashboard: ${pc.cyan(dashboardUrl)}` + : " - Check your cloud provider dashboard to stop or delete any unused servers"; switch (exitCode) { case 130: return [ "Script was interrupted (Ctrl+C).", "Note: If a server was already created, it may still be running.", - " Check your cloud provider dashboard to stop or delete any unused servers.", + dashboardHint, ]; case 137: return [ "Script was killed (likely by the system due to timeout or out of memory).", " - The server may not have enough RAM for this agent", " - Try a larger instance size or a different cloud provider", - " - Check your cloud provider dashboard to stop or delete any unused servers", + dashboardHint, ]; case 255: return [ @@ -780,6 +787,7 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string, ...credentialHints(cloud, authHint), " - Cloud provider API error (quota, rate limit, or region issue)", " - Server provisioning failed (try again or pick a different region)", + ...(dashboardUrl ? [` - Check your dashboard: ${pc.cyan(dashboardUrl)}`] : []), ]; default: return [ @@ -787,6 +795,7 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string, ...credentialHints(cloud, authHint, "Missing"), " - Cloud provider API rate limit or quota exceeded", " - Missing local dependencies (SSH, curl, jq)", + ...(dashboardUrl ? [` - Check your dashboard: ${pc.cyan(dashboardUrl)}`] : []), ]; } } @@ -801,7 +810,7 @@ export function buildRetryCommand(agent: string, cloud: string, prompt?: string) return `spawn ${agent} ${cloud} --prompt-file `; } -function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string): never { +function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string): never { p.log.error("Spawn script failed"); console.error("\nError:", errMsg); @@ -813,8 +822,8 @@ function reportScriptFailure(errMsg: string, cloud: string, agent: string, authH const signal = signalMatch ? signalMatch[1] : null; const lines = signal - ? getSignalGuidance(signal) - : getScriptFailureGuidance(exitCode, cloud, authHint); + ? getSignalGuidance(signal, dashboardUrl) + : getScriptFailureGuidance(exitCode, cloud, authHint, dashboardUrl); console.error(""); for (const line of lines) console.error(line); console.error(""); @@ -833,23 +842,27 @@ export function isRetryableExitCode(errMsg: string): boolean { return code === 255; } -function handleUserInterrupt(errMsg: string): void { +function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void { if (!errMsg.includes("interrupted by user") && !errMsg.includes("killed by SIGINT")) return; console.error(); p.log.warn("Script interrupted (Ctrl+C)."); p.log.warn("If a server was already created, it may still be running."); - p.log.warn(` Check your cloud provider dashboard to stop or delete any unused servers.`); + if (dashboardUrl) { + p.log.warn(` Check your dashboard: ${pc.cyan(dashboardUrl)}`); + } else { + p.log.warn(` Check your cloud provider dashboard to stop or delete any unused servers.`); + } process.exit(130); } -async function runWithRetries(script: string, prompt?: string): Promise { +async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string): Promise { for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) { try { await runBash(script, prompt); return undefined; // success } catch (err) { const errMsg = getErrorMessage(err); - handleUserInterrupt(errMsg); + handleUserInterrupt(errMsg, dashboardUrl); if (attempt <= MAX_RETRIES && isRetryableExitCode(errMsg)) { const delay = RETRY_DELAYS[attempt - 1]; @@ -864,7 +877,7 @@ async function runWithRetries(script: string, prompt?: string): Promise { +async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string): Promise { const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`; const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`; @@ -887,9 +900,9 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin // Non-fatal: don't block the spawn if history write fails } - const lastErr = await runWithRetries(scriptContent, prompt); + const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl); if (lastErr) { - reportScriptFailure(lastErr, cloud, agent, authHint, prompt); + reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl); } }