Skip to content
Closed
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": "1.0.44",
"version": "1.0.45",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
24 changes: 11 additions & 13 deletions packages/cli/src/__tests__/digitalocean-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,23 +88,20 @@ describe("doApi 401 OAuth recovery", () => {

it("attempts OAuth recovery on 401 before throwing", async () => {
state.token = "expired-token";
let callCount = 0;
let apiCalls = 0;
let oauthCalls = 0;
globalThis.fetch = mock((url: string | URL | Request) => {
callCount++;
const urlStr = String(url);
// First call: the actual API call returning 401
if (callCount === 1) {
return Promise.resolve(
new Response("Unauthorized", {
status: 401,
}),
);
}
// Second call: OAuth connectivity check — fail it so tryDoOAuth returns null quickly
// OAuth connectivity check — fail it so tryDoOAuth returns null quickly
// (avoids starting a real Bun.serve OAuth server)
if (urlStr.includes("cloud.digitalocean.com")) {
oauthCalls++;
return Promise.reject(new Error("network unavailable"));
}
// Track only DO API calls (other test files may share globalThis.fetch)
if (urlStr.includes("api.digitalocean.com")) {
apiCalls++;
}
return Promise.resolve(
new Response("Unauthorized", {
status: 401,
Expand All @@ -114,8 +111,9 @@ describe("doApi 401 OAuth recovery", () => {

// OAuth recovery fails (connectivity check fails), so doApi throws the 401
await expect(doApi("GET", "/account", undefined, 1)).rejects.toThrow("DigitalOcean API error 401");
// Verify recovery was attempted: 1 API call + 1 connectivity check = 2
expect(callCount).toBe(2);
// Verify recovery was attempted: 1 API call + 1 OAuth connectivity check
expect(apiCalls).toBe(1);
expect(oauthCalls).toBe(1);
});

it("succeeds after OAuth recovery provides a new token", async () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/__tests__/hermes-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { isString } from "@openrouter/spawn-shared";
import { mockClackPrompts } from "./test-helpers";

// ── Mock @clack/prompts (must be before importing agent-setup) ──────────
Expand Down Expand Up @@ -97,4 +98,49 @@ describe("startHermesDashboard", () => {
expect(capturedScript).not.toContain("/etc/systemd/system/");
expect(capturedScript).not.toContain("crontab");
});

it("probes hermes --help for 'dashboard' subcommand before launching (issue #3407)", () => {
// If the installed hermes lacks the dashboard subcommand, we should bail
// early instead of launching and waiting 60s for a timeout.
expect(capturedScript).toContain("--help");
expect(capturedScript).toContain("does not support the dashboard subcommand");
// The probe must come BEFORE the setsid/nohup launch.
const probeIdx = capturedScript.indexOf("--help");
const launchIdx = capturedScript.indexOf("setsid");
expect(probeIdx).toBeLessThan(launchIdx);
});
});

describe("startHermesDashboard — failure surfacing", () => {
let stderrSpy: ReturnType<typeof spyOn>;
let warnings: string[];

beforeEach(() => {
warnings = [];
stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => {
const text = isString(chunk) ? chunk : new TextDecoder().decode(chunk);
warnings.push(text);
return true;
});
});

afterEach(() => {
stderrSpy.mockRestore();
});

it("includes the runServer error message in the warning so users can grep it", async () => {
const failing: CloudRunner = {
runServer: mock(async () => {
throw new Error("run_server failed (exit 1): hermes dashboard ...");
}),
uploadFile: mock(async () => {}),
downloadFile: mock(async () => {}),
};
// Should NOT throw — dashboard failure is non-fatal.
await startHermesDashboard(failing);
const combined = warnings.join("");
// Surfaces the underlying cause, not a generic message.
expect(combined).toContain("run_server failed (exit 1)");
expect(combined).toContain("TUI still available");
});
});
84 changes: 44 additions & 40 deletions packages/cli/src/__tests__/hetzner-cov.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,21 +585,14 @@ describe("hetzner/createServer", () => {
},
},
};
let callCount = 0;
global.fetch = mock(() => {
callCount++;
if (callCount <= 1) {
// Token validation
return Promise.resolve(
new Response(
JSON.stringify({
servers: [],
}),
),
);
}
if (callCount <= 2) {
// SSH keys
// Route by URL + method to avoid mock pollution from concurrent test files.
// POST /servers is called twice: first fails with resource_limit_exceeded,
// second succeeds after orphaned IP cleanup.
let serverPostCount = 0;
global.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
const urlStr = String(url);
const method = init?.method ?? "GET";
if (urlStr.includes("/ssh_keys")) {
return Promise.resolve(
new Response(
JSON.stringify({
Expand All @@ -608,23 +601,15 @@ describe("hetzner/createServer", () => {
),
);
}
if (callCount <= 3) {
// First create attempt — resource_limit_exceeded (HTTP 403)
if (urlStr.includes("/primary_ips/")) {
// Delete orphaned IP
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "resource_limit_exceeded",
message: "primary_ip_limit",
},
}),
{
status: 403,
},
),
new Response("", {
status: 204,
}),
);
}
if (callCount <= 4) {
if (urlStr.includes("/primary_ips")) {
// List primary IPs for cleanup
return Promise.resolve(
new Response(
Expand All @@ -645,23 +630,42 @@ describe("hetzner/createServer", () => {
),
);
}
if (callCount <= 5) {
// Delete orphaned IP 100
return Promise.resolve(
new Response("", {
status: 204,
}),
);
if (urlStr.includes("/servers") && method === "POST") {
serverPostCount++;
if (serverPostCount === 1) {
// First create attempt — resource_limit_exceeded (HTTP 403)
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "resource_limit_exceeded",
message: "primary_ip_limit",
},
}),
{
status: 403,
},
),
);
}
// Retry create — success
return Promise.resolve(new Response(JSON.stringify(serverResp)));
}
// Retry create — success
return Promise.resolve(new Response(JSON.stringify(serverResp)));
// Default: token validation (GET /servers) and other GETs
return Promise.resolve(
new Response(
JSON.stringify({
servers: [],
}),
),
);
});
const { ensureHcloudToken, createServer } = await import("../hetzner/hetzner");
await ensureHcloudToken();
const conn = await createServer("test-retry", "cx23", "fsn1");
expect(conn.ip).toBe("10.0.0.5");
// Should have called: token(1), ssh_keys(2), create-fail(3), list-ips(4), delete-ip(5), create-ok(6)
expect(callCount).toBeGreaterThanOrEqual(6);
// POST /servers was called twice: once failing, once succeeding
expect(serverPostCount).toBe(2);
});

it("throws with guidance when resource limit hit and no orphaned IPs to clean", async () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/shared/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,13 @@ export async function startHermesDashboard(runner: CloudRunner): Promise<void> {
hermesPath,
`if ${portCheck}; then echo "Hermes dashboard already running on :9119"; exit 0; fi`,
"_hermes_bin=$(command -v hermes) || { echo 'hermes not found in PATH' >&2; exit 1; }",
// Capability probe: bail early if the installed hermes doesn't have the
// `dashboard` subcommand (issue #3407 — older versions or partial installs
// lack it, causing a confusing argparse error after a 60s timeout).
'if ! "$_hermes_bin" --help 2>&1 | grep -q "^[[:space:]]*dashboard"; then',
' echo "hermes does not support the dashboard subcommand — skipping" >&2',
" exit 1",
"fi",
// --no-open: we're on a remote VM, don't try to spawn a browser there.
// --host 127.0.0.1: loopback-only; the SSH tunnel is how the user reaches it.
"if command -v setsid >/dev/null 2>&1; then",
Expand All @@ -749,7 +756,8 @@ export async function startHermesDashboard(runner: CloudRunner): Promise<void> {
logInfo("Hermes web dashboard started on :9119");
} else {
// Non-fatal: the TUI still works even if the dashboard didn't come up.
logWarn("Hermes web dashboard failed to start — TUI still available");
// Surface the error so users see the real cause (e.g. missing subcommand).
logWarn(`Hermes web dashboard failed to start — TUI still available (${getErrorMessage(result.error)})`);
}
}

Expand Down
Loading