Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/cli/src/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ bun test src/__tests__/manifest.test.ts
- `hermes-dashboard.test.ts` — `startHermesDashboard` session-scoped `hermes dashboard` launch on :9119 with setsid/nohup
- `digitalocean-token.test.ts` — DigitalOcean token storage, retrieval, and API client helpers
- `do-min-size.test.ts` — DigitalOcean minimum droplet size enforcement: `slugRamGb` RAM comparison, `AGENT_MIN_SIZE` map
- `do-payment-warning.test.ts` — `ensureDoToken` proactive payment method reminder for first-time DigitalOcean users
- `do-payment-warning.test.ts` — `ensureDoToken` does not preemptively warn about payment; billing URL covered via `handleBillingError` tests
- `readiness-checklist.test.ts` — `checklistLineStatus` mapping for DigitalOcean readiness rows
- `readiness.test.ts` — `sortBlockers` resolution order for DigitalOcean readiness blockers
- `do-snapshot.test.ts` — `findSpawnSnapshot`: DigitalOcean snapshot lookup, filtering, error handling
- `hetzner-pagination.test.ts` — Hetzner API pagination: multi-page server listing and cursor handling
- `sprite-keep-alive.test.ts` — `installSpriteKeepAlive` download/install, graceful failure, session script wrapping
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/__tests__/billing-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BillingGuidanceDeps } from "../shared/billing-guidance";

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { awsBilling } from "../aws/billing";
import { digitaloceanBilling } from "../digitalocean/billing";
import { DIGITALOCEAN_BILLING_ADD_PAYMENT_URL, digitaloceanBilling } from "../digitalocean/billing";
import { gcpBilling } from "../gcp/billing";
import { hetznerBilling } from "../hetzner/billing";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
Expand Down Expand Up @@ -142,6 +142,14 @@ describe("handleBillingError", () => {
expect(result).toBe(false);
});

it("opens DigitalOcean add-payment billing URL (readiness payment_required step)", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();
const result = await handleBillingError(digitaloceanBilling, deps);
expect(result).toBe(true);
expect(deps.openBrowser).toHaveBeenCalledWith(DIGITALOCEAN_BILLING_ADD_PAYMENT_URL);
});

it("works for config without billing URL", async () => {
mockPrompt.mockImplementation(() => Promise.resolve(""));
const deps = createMockDeps();
Expand Down
22 changes: 7 additions & 15 deletions packages/cli/src/__tests__/do-payment-warning.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* do-payment-warning.test.ts
*
* Verifies that ensureDoToken() shows a proactive payment method reminder to
* first-time DigitalOcean users who have no saved config and no env token.
* Verifies that ensureDoToken() does not show a preemptive payment-method banner
* before OAuth (billing guidance is shown when resolving the payment_required
* readiness step via handleBillingError).
*
* Uses spyOn on the real ui module to avoid mock.module contamination.
*/
Expand All @@ -16,7 +17,7 @@ mockClackPrompts();

const { ensureDoToken } = await import("../digitalocean/digitalocean");

describe("ensureDoToken — payment method warning for first-time users", () => {
describe("ensureDoToken — no preemptive payment banner before OAuth", () => {
const savedEnv: Record<string, string | undefined> = {};
const originalFetch = globalThis.fetch;
let stderrSpy: ReturnType<typeof spyOn>;
Expand Down Expand Up @@ -62,12 +63,12 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
}
});

it("shows payment method warning for first-time users (no saved token, no env var)", async () => {
it("does not show payment method warning for first-time users (no saved token, no env var)", async () => {
await expect(ensureDoToken()).rejects.toThrow("User chose to exit");

const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(true);
expect(warnMessages.some((msg: string) => msg.includes("cloud.digitalocean.com/account/billing"))).toBe(true);
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
expect(warnMessages.some((msg: string) => msg.includes("cloud.digitalocean.com/account/billing"))).toBe(false);
});

it("does NOT show payment warning when a saved token exists (returning user)", async () => {
Expand Down Expand Up @@ -105,13 +106,4 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});

it("billing URL in warning points to the DigitalOcean billing page", async () => {
await expect(ensureDoToken()).rejects.toThrow("User chose to exit");

const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
const billingWarning = warnMessages.find((msg: string) => msg.includes("billing"));
expect(billingWarning).toBeDefined();
expect(billingWarning).toContain("https://cloud.digitalocean.com/account/billing");
});
});
40 changes: 36 additions & 4 deletions packages/cli/src/__tests__/preflight-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import type { Manifest } from "../manifest";

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { preflightCredentialCheck } from "../commands/index.js";
import * as shared from "../commands/shared.js";
import { mockClackPrompts } from "./test-helpers";

// Must be called before dynamic imports that use @clack/prompts
const clack = mockClackPrompts();

function makeManifest(cloudAuth: string): Manifest {
function makeManifest(cloudAuth: string, cloudKey = "testcloud"): Manifest {
return {
agents: {},
clouds: {
testcloud: {
name: "Test Cloud",
[cloudKey]: {
name: cloudKey === "digitalocean" ? "DigitalOcean" : "Test Cloud",
description: "A test cloud",
price: "test",
url: "https://test.cloud",
Expand Down Expand Up @@ -115,4 +116,35 @@ describe("preflightCredentialCheck", () => {
await preflightCredentialCheck(makeManifest("none"), "testcloud");
expect(clack.logWarn.mock.calls.length).toBe(0);
});

describe("digitalocean + TTY gating", () => {
let isTTYSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
isTTYSpy = spyOn(shared, "isInteractiveTTY");
});

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

it("skips warnings when interactive (guided checklist supplies credentials)", async () => {
isTTYSpy.mockReturnValue(true);
clearEnv("OPENROUTER_API_KEY");
clearEnv("DIGITALOCEAN_ACCESS_TOKEN");
await preflightCredentialCheck(makeManifest("DIGITALOCEAN_ACCESS_TOKEN", "digitalocean"), "digitalocean");
expect(clack.logWarn.mock.calls.length).toBe(0);
});

it("still warns when not interactive", async () => {
isTTYSpy.mockReturnValue(false);
clearEnv("OPENROUTER_API_KEY");
clearEnv("DIGITALOCEAN_ACCESS_TOKEN");
await preflightCredentialCheck(makeManifest("DIGITALOCEAN_ACCESS_TOKEN", "digitalocean"), "digitalocean");
expect(clack.logWarn.mock.calls.length).toBeGreaterThan(0);
const warnText = String(clack.logWarn.mock.calls[0]?.[0] ?? "");
expect(warnText).toContain("Missing credentials");
expect(warnText).toMatch(/DIGITALOCEAN_ACCESS_TOKEN|OPENROUTER_API_KEY/);
});
});
});
41 changes: 41 additions & 0 deletions packages/cli/src/__tests__/readiness-checklist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ReadinessState } from "../digitalocean/readiness";

import { describe, expect, test } from "bun:test";
import { checklistLineStatus } from "../digitalocean/readiness-checklist";

describe("checklistLineStatus", () => {
test("all ready when status READY", () => {
const state: ReadinessState = {
status: "READY",
blockers: [],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("droplet_limit", state)).toBe("ready");
});

test("do_auth blocks only auth row; other rows pending", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"do_auth",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("blocked");
expect(checklistLineStatus("email_unverified", state)).toBe("pending");
expect(checklistLineStatus("ssh_missing", state)).toBe("pending");
});

test("multiple blockers without do_auth", () => {
const state: ReadinessState = {
status: "BLOCKED",
blockers: [
"email_unverified",
"payment_required",
],
};
expect(checklistLineStatus("do_auth", state)).toBe("ready");
expect(checklistLineStatus("email_unverified", state)).toBe("blocked");
expect(checklistLineStatus("payment_required", state)).toBe("blocked");
expect(checklistLineStatus("ssh_missing", state)).toBe("ready");
});
});
16 changes: 16 additions & 0 deletions packages/cli/src/__tests__/readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import { sortBlockers } from "../digitalocean/readiness";

describe("sortBlockers", () => {
test("payment_required resolves before ssh_missing", () => {
expect(
sortBlockers([
"ssh_missing",
"payment_required",
]),
).toEqual([
"payment_required",
"ssh_missing",
]);
});
});
5 changes: 5 additions & 0 deletions packages/cli/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,11 @@ export async function preflightCredentialCheck(manifest: Manifest, cloud: string
return;
}

// Interactive DigitalOcean runs use the guided readiness checklist for credentials and OpenRouter.
if (cloud === "digitalocean" && isInteractiveTTY()) {
return;
}

const authVars = parseAuthEnvVars(cloudAuth);
const missing = collectMissingCredentials(authVars, cloud);
if (missing.length === 0) {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/digitalocean/billing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { BillingConfig } from "../shared/billing-guidance.js";

/** Opens add-payment modal and skips billing questionnaire (Spawn / OpenRouter context). */
export const DIGITALOCEAN_BILLING_ADD_PAYMENT_URL =
"https://cloud.digitalocean.com/account/billing?defer-onboarding-for=or&open-add-payment-method=true";

export const digitaloceanBilling: BillingConfig = {
billingUrl: "https://cloud.digitalocean.com/account/billing",
billingUrl: DIGITALOCEAN_BILLING_ADD_PAYMENT_URL,
setupSteps: [
"1. Open DigitalOcean Billing Settings",
"2. Add a credit card or PayPal account",
Expand Down
Loading
Loading