diff --git a/CLAUDE.md b/CLAUDE.md index bcb38425..f89564e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -285,6 +285,7 @@ Without these, the cloud has **no test coverage**, body validation is missing, m - **Major** (X.0.0 → X+1.0.0): Breaking changes - The CLI has auto-update enabled — users get new versions immediately on next run - Version bumps ensure users always have the latest fixes and features +- **NEVER commit `cli/cli.js`** — it is a build artifact (already in `.gitignore`). It is produced during releases, not checked into the repo. Do NOT use `git add -f cli/cli.js`. ## Autonomous Loops diff --git a/cli/package.json b/cli/package.json index 7556104c..f3dd571e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.83", + "version": "0.2.86", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/commands.ts b/cli/src/commands.ts index e0261dcb..38f0324d 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -656,7 +656,7 @@ export async function preflightCredentialCheck(manifest: Manifest, cloud: string } } -export async function cmdRun(agent: string, cloud: string, prompt?: string, dryRun?: boolean): Promise { +export async function cmdRun(agent: string, cloud: string, prompt?: string, dryRun?: boolean, debug?: boolean): Promise { const manifest = await loadManifestWithSpinner(); ({ agent, cloud } = resolveAndLog(manifest, agent, cloud)); @@ -676,7 +676,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), manifest.clouds[cloud].url); + await execScript(cloud, agent, prompt, getAuthHint(manifest, cloud), manifest.clouds[cloud].url, debug); } export function getStatusDescription(status: number): string { @@ -1070,10 +1070,10 @@ function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void { process.exit(130); } -async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string): Promise { +async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string, debug?: boolean): Promise { for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) { try { - await runBash(script, prompt); + await runBash(script, prompt, debug); return undefined; // success } catch (err) { const errMsg = getErrorMessage(err); @@ -1092,7 +1092,7 @@ async function runWithRetries(script: string, prompt?: string, dashboardUrl?: st return "Script failed after all retries"; } -async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string): Promise { +async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string, debug?: boolean): Promise { const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`; const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`; @@ -1116,13 +1116,13 @@ 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, dashboardUrl); + const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl, debug); if (lastErr) { reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl); } } -function runBash(script: string, prompt?: string): Promise { +function runBash(script: string, prompt?: string, debug?: boolean): Promise { // SECURITY: Validate script content before execution validateScriptContent(script); @@ -1132,6 +1132,9 @@ function runBash(script: string, prompt?: string): Promise { env.SPAWN_PROMPT = prompt; env.SPAWN_MODE = "non-interactive"; } + if (debug) { + env.SPAWN_DEBUG = "1"; + } return new Promise((resolve, reject) => { const child = spawn("bash", ["-c", script], { diff --git a/cli/src/index.ts b/cli/src/index.ts index af8e34d5..caf508eb 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -65,6 +65,7 @@ const KNOWN_FLAGS = new Set([ "--version", "-v", "-V", "--prompt", "-p", "--prompt-file", "-f", "--dry-run", "-n", + "--debug", "-a", "-c", "--agent", "--cloud", "--clear", ]); @@ -93,6 +94,7 @@ function checkUnknownFlags(args: string[]): void { console.error(` ${pc.cyan("--prompt, -p")} Provide a prompt for non-interactive execution`); console.error(` ${pc.cyan("--prompt-file, -f")} Read prompt from a file`); console.error(` ${pc.cyan("--dry-run, -n")} Preview what would be provisioned`); + console.error(` ${pc.cyan("--debug")} Show all commands being executed`); console.error(` ${pc.cyan("--help, -h")} Show help information`); console.error(` ${pc.cyan("--version, -v")} Show version`); console.error(); @@ -143,13 +145,13 @@ async function showInfoOrError(name: string): Promise { showUnknownCommandError(name, manifest); } -async function handleDefaultCommand(agent: string, cloud: string | undefined, prompt?: string, dryRun?: boolean): Promise { +async function handleDefaultCommand(agent: string, cloud: string | undefined, prompt?: string, dryRun?: boolean, debug?: boolean): Promise { if (cloud && HELP_FLAGS.includes(cloud)) { await showInfoOrError(agent); return; } if (cloud) { - await cmdRun(agent, cloud, prompt, dryRun); + await cmdRun(agent, cloud, prompt, dryRun, debug); return; } if (dryRun) { @@ -428,11 +430,11 @@ async function dispatchSubcommand(cmd: string, filteredArgs: string[]): Promise< } /** Handle verb aliases like "spawn run claude sprite" -> "spawn claude sprite" */ -async function dispatchVerbAlias(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean): Promise { +async function dispatchVerbAlias(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean, debug: boolean): Promise { if (filteredArgs.length > 1) { const remaining = filteredArgs.slice(1); warnExtraArgs(remaining, 2); - await handleDefaultCommand(remaining[0], remaining[1], prompt, dryRun); + await handleDefaultCommand(remaining[0], remaining[1], prompt, dryRun, debug); return; } console.error(pc.red(`Error: ${pc.bold(cmd)} requires an agent and cloud`)); @@ -442,19 +444,19 @@ async function dispatchVerbAlias(cmd: string, filteredArgs: string[], prompt: st } /** Handle slash notation: "spawn claude/hetzner" -> "spawn claude hetzner" */ -async function dispatchSlashNotation(cmd: string, prompt: string | undefined, dryRun: boolean): Promise { +async function dispatchSlashNotation(cmd: string, prompt: string | undefined, dryRun: boolean, debug: boolean): Promise { const parts = cmd.split("/"); if (parts.length === 2 && parts[0] && parts[1]) { console.error(pc.dim(`Tip: use a space instead of slash: ${pc.cyan(`spawn ${parts[0]} ${parts[1]}`)}`)); console.error(); - await handleDefaultCommand(parts[0], parts[1], prompt, dryRun); + await handleDefaultCommand(parts[0], parts[1], prompt, dryRun, debug); return true; } return false; } /** Dispatch a named command or fall through to agent/cloud handling */ -async function dispatchCommand(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean): Promise { +async function dispatchCommand(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean, debug: boolean): Promise { if (IMMEDIATE_COMMANDS[cmd]) { warnExtraArgs(filteredArgs, 1); IMMEDIATE_COMMANDS[cmd](); @@ -463,14 +465,14 @@ async function dispatchCommand(cmd: string, filteredArgs: string[], prompt: stri if (LIST_COMMANDS.has(cmd)) { await dispatchListCommand(filteredArgs); return; } if (SUBCOMMANDS[cmd]) { await dispatchSubcommand(cmd, filteredArgs); return; } - if (VERB_ALIASES.has(cmd)) { await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun); return; } + if (VERB_ALIASES.has(cmd)) { await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug); return; } if (filteredArgs.length === 1 && cmd.includes("/")) { - if (await dispatchSlashNotation(cmd, prompt, dryRun)) return; + if (await dispatchSlashNotation(cmd, prompt, dryRun, debug)) return; } warnExtraArgs(filteredArgs, 2); - await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun); + await handleDefaultCommand(filteredArgs[0], filteredArgs[1], prompt, dryRun, debug); } async function main(): Promise { @@ -485,6 +487,11 @@ async function main(): Promise { const dryRun = dryRunIdx !== -1; if (dryRun) filteredArgs.splice(dryRunIdx, 1); + // Extract --debug boolean flag + const debugIdx = filteredArgs.findIndex(a => a === "--debug"); + const debug = debugIdx !== -1; + if (debug) filteredArgs.splice(debugIdx, 1); + checkUnknownFlags(filteredArgs); const cmd = filteredArgs[0]; @@ -493,7 +500,7 @@ async function main(): Promise { if (!cmd) { await handleNoCommand(prompt, dryRun); } else { - await dispatchCommand(cmd, filteredArgs, prompt, dryRun); + await dispatchCommand(cmd, filteredArgs, prompt, dryRun, debug); } } catch (err) { handleError(err); diff --git a/hetzner/claude.sh b/hetzner/claude.sh index 4ac7191b..7d305443 100755 --- a/hetzner/claude.sh +++ b/hetzner/claude.sh @@ -27,15 +27,18 @@ create_server "${SERVER_NAME}" verify_server_connectivity "${HETZNER_SERVER_IP}" wait_for_cloud_init "${HETZNER_SERVER_IP}" 60 -# 5. Verify Claude Code is installed (fallback to manual install) +# 5. Verify Claude Code is installed (try curl first, then bun fallback) log_step "Verifying Claude Code installation..." -if ! run_server "${HETZNER_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && command -v claude" >/dev/null 2>&1; then - log_step "Claude Code not found, installing manually..." - run_server "${HETZNER_SERVER_IP}" "curl -fsSL https://claude.ai/install.sh | bash" +if ! run_server "${HETZNER_SERVER_IP}" "export PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$PATH && command -v claude" >/dev/null 2>&1; then + log_step "Claude Code not found, installing..." + if ! run_server "${HETZNER_SERVER_IP}" "curl -fsSL https://claude.ai/install.sh | bash"; then + log_warn "curl install failed, falling back to bun..." + run_server "${HETZNER_SERVER_IP}" "export PATH=\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH && bun add -g @anthropic-ai/claude-code && claude install" + fi fi # Verify installation succeeded -if ! run_server "${HETZNER_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && command -v claude &> /dev/null && claude --version &> /dev/null"; then +if ! run_server "${HETZNER_SERVER_IP}" "export PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$PATH && command -v claude &> /dev/null && claude --version &> /dev/null"; then log_install_failed "Claude Code" "curl -fsSL https://claude.ai/install.sh | bash" "${HETZNER_SERVER_IP}" exit 1 fi diff --git a/shared/common.sh b/shared/common.sh index 9bc53b6c..6cb9020b 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -7,6 +7,15 @@ # It does not set bash flags (like set -eo pipefail) as those should be set # by the scripts that source this file. +# ============================================================ +# Debug mode +# ============================================================ + +# Enable debug output if SPAWN_DEBUG is set +if [[ -n "${SPAWN_DEBUG:-}" ]]; then + set -x +fi + # ============================================================ # Color definitions and logging # ============================================================ @@ -1759,6 +1768,9 @@ wait_for_cloud_init() { ssh_run_server() { local ip="${1}" local cmd="${2}" + if [[ -n "${SPAWN_DEBUG:-}" ]]; then + cmd="set -x; ${cmd}" + fi # shellcheck disable=SC2086 ssh $SSH_OPTS "${SSH_USER:-root}@${ip}" -- "${cmd}" }