diff --git a/cli/src/__tests__/script-failure-guidance.test.ts b/cli/src/__tests__/script-failure-guidance.test.ts index 219338d4..cd6d4d1f 100644 --- a/cli/src/__tests__/script-failure-guidance.test.ts +++ b/cli/src/__tests__/script-failure-guidance.test.ts @@ -632,6 +632,66 @@ describe("buildRetryCommand", () => { it("should return simple command when prompt is empty string", () => { expect(buildRetryCommand("codex", "vultr", "")).toBe("spawn codex vultr"); }); + + // ── spawnName parameter (issue #1709) ──────────────────────────────────── + + it("should include --name flag when spawnName is provided without prompt", () => { + expect(buildRetryCommand("claude", "hetzner", undefined, "my-box")).toBe( + 'spawn claude hetzner --name "my-box"' + ); + }); + + it("should include --name flag when spawnName is provided with short prompt", () => { + expect(buildRetryCommand("claude", "hetzner", "Fix all bugs", "my-box")).toBe( + 'spawn claude hetzner --name "my-box" --prompt "Fix all bugs"' + ); + }); + + it("should include --name flag when spawnName is provided with long prompt", () => { + const longPrompt = "A".repeat(100); + const result = buildRetryCommand("claude", "hetzner", longPrompt, "my-box"); + expect(result).toBe('spawn claude hetzner --name "my-box" --prompt-file '); + }); + + it("should not include --name flag when spawnName is undefined", () => { + expect(buildRetryCommand("claude", "hetzner", undefined, undefined)).toBe( + "spawn claude hetzner" + ); + expect(buildRetryCommand("claude", "hetzner")).toBe("spawn claude hetzner"); + }); + + it("should not include --name flag when spawnName is empty string", () => { + expect(buildRetryCommand("claude", "hetzner", undefined, "")).toBe( + "spawn claude hetzner" + ); + }); + + it("should place --name before --prompt in the command", () => { + const result = buildRetryCommand("codex", "sprite", "short prompt", "dev-server"); + expect(result).toBe('spawn codex sprite --name "dev-server" --prompt "short prompt"'); + // Verify ordering: --name comes before --prompt + const nameIdx = result.indexOf("--name"); + const promptIdx = result.indexOf("--prompt"); + expect(nameIdx).toBeLessThan(promptIdx); + }); + + it("should quote --name value when it contains spaces", () => { + expect(buildRetryCommand("claude", "hetzner", undefined, "my dev box")).toBe( + 'spawn claude hetzner --name "my dev box"' + ); + }); + + it("should escape double quotes in --name value", () => { + expect(buildRetryCommand("claude", "hetzner", undefined, 'my "box"')).toBe( + 'spawn claude hetzner --name "my \\"box\\""' + ); + }); + + it("should always quote --name value to prevent shell injection", () => { + // Names with shell metacharacters should be safely quoted + const result = buildRetryCommand("claude", "hetzner", undefined, "foo; rm -rf"); + expect(result).toBe('spawn claude hetzner --name "foo; rm -rf"'); + }); }); describe("dashboard URL in guidance", () => { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 7ad33242..349b12cf 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -1258,17 +1258,19 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string, return lines; } -export function buildRetryCommand(agent: string, cloud: string, prompt?: string): string { - if (!prompt) return `spawn ${agent} ${cloud}`; +export function buildRetryCommand(agent: string, cloud: string, prompt?: string, spawnName?: string): string { + const safeName = spawnName ? spawnName.replace(/"/g, '\\"') : ""; + const nameFlag = spawnName ? ` --name "${safeName}"` : ""; + if (!prompt) return `spawn ${agent} ${cloud}${nameFlag}`; if (prompt.length <= 80) { const safe = prompt.replace(/"/g, '\\"'); - return `spawn ${agent} ${cloud} --prompt "${safe}"`; + return `spawn ${agent} ${cloud}${nameFlag} --prompt "${safe}"`; } // Long prompts: suggest --prompt-file instead of truncating into a broken command - return `spawn ${agent} ${cloud} --prompt-file `; + return `spawn ${agent} ${cloud}${nameFlag} --prompt-file `; } -function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string): never { +function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string, spawnName?: string): never { p.log.error("Spawn script failed"); console.error("\nError:", errMsg); @@ -1285,7 +1287,7 @@ function reportScriptFailure(errMsg: string, cloud: string, agent: string, authH console.error(""); for (const line of lines) console.error(line); console.error(""); - console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt))}`); + console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt, spawnName))}`); process.exit(1); } @@ -1366,7 +1368,7 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl, debug, spawnName); if (lastErr) { - reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl); + reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl, spawnName); } } @@ -1668,7 +1670,7 @@ async function showEmptyListMessage(agentFilter?: string, cloudFilter?: string): function buildListFooterLines(records: SpawnRecord[], agentFilter?: string, cloudFilter?: string): string[] { const lines: string[] = []; const latest = records[0]; - lines.push(`Rerun last: ${pc.cyan(buildRetryCommand(latest.agent, latest.cloud, latest.prompt))}`); + lines.push(`Rerun last: ${pc.cyan(buildRetryCommand(latest.agent, latest.cloud, latest.prompt, latest.name))}`); if (agentFilter || cloudFilter) { const totalRecords = filterHistory(); @@ -1949,7 +1951,8 @@ async function handleRecordAction( manifest: Manifest | null ): Promise { if (!selected.connection) { - // No connection info -- just rerun + // No connection info -- just rerun, reusing the existing spawn name + if (selected.name) process.env.SPAWN_NAME = selected.name; p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`); await cmdRun(selected.agent, selected.cloud, selected.prompt); return; @@ -2039,7 +2042,8 @@ async function handleRecordAction( return; } - // Rerun (create new spawn) + // Rerun (create new spawn), reusing the existing spawn name + if (selected.name) process.env.SPAWN_NAME = selected.name; p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`); await cmdRun(selected.agent, selected.cloud, selected.prompt); } @@ -2182,6 +2186,7 @@ export async function cmdLast(): Promise { const label = buildRecordLabel(latest, manifest); const hint = buildRecordHint(latest); p.log.step(`Rerunning last spawn: ${pc.bold(label)} ${pc.dim(hint)}`); + if (latest.name) process.env.SPAWN_NAME = latest.name; await cmdRun(latest.agent, latest.cloud, latest.prompt); }