fix: Hetzner Claude Code installation + add --debug mode (#1198)

Fixed Hetzner installation issue where curl to claude.ai/install.sh
was returning 403 errors. Added fallback to use bun (already installed
by cloud-init) to install Claude Code.

Also added --debug flag to enable verbose bash output (set -x) for
easier troubleshooting.

Changes:
- hetzner/claude.sh: Use bun fallback installation method
- CLI: Added --debug flag support (v0.2.86)
- shared/common.sh: Enable set -x when SPAWN_DEBUG=1

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-15 16:37:04 -08:00 committed by GitHub
parent 6c8ab77bd0
commit 01ed74ba95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 50 additions and 24 deletions

View file

@ -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

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.83",
"version": "0.2.86",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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<void> {
export async function cmdRun(agent: string, cloud: string, prompt?: string, dryRun?: boolean, debug?: boolean): Promise<void> {
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<string | undefined> {
async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string, debug?: boolean): Promise<string | undefined> {
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<void> {
async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string, debug?: boolean): Promise<void> {
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<void> {
function runBash(script: string, prompt?: string, debug?: boolean): Promise<void> {
// SECURITY: Validate script content before execution
validateScriptContent(script);
@ -1132,6 +1132,9 @@ function runBash(script: string, prompt?: string): Promise<void> {
env.SPAWN_PROMPT = prompt;
env.SPAWN_MODE = "non-interactive";
}
if (debug) {
env.SPAWN_DEBUG = "1";
}
return new Promise<void>((resolve, reject) => {
const child = spawn("bash", ["-c", script], {

View file

@ -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<void> {
showUnknownCommandError(name, manifest);
}
async function handleDefaultCommand(agent: string, cloud: string | undefined, prompt?: string, dryRun?: boolean): Promise<void> {
async function handleDefaultCommand(agent: string, cloud: string | undefined, prompt?: string, dryRun?: boolean, debug?: boolean): Promise<void> {
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<void> {
async function dispatchVerbAlias(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean, debug: boolean): Promise<void> {
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<boolean> {
async function dispatchSlashNotation(cmd: string, prompt: string | undefined, dryRun: boolean, debug: boolean): Promise<boolean> {
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<void> {
async function dispatchCommand(cmd: string, filteredArgs: string[], prompt: string | undefined, dryRun: boolean, debug: boolean): Promise<void> {
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<void> {
@ -485,6 +487,11 @@ async function main(): Promise<void> {
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<void> {
if (!cmd) {
await handleNoCommand(prompt, dryRun);
} else {
await dispatchCommand(cmd, filteredArgs, prompt, dryRun);
await dispatchCommand(cmd, filteredArgs, prompt, dryRun, debug);
}
} catch (err) {
handleError(err);

View file

@ -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

View file

@ -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}"
}