mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat: enforce CI coverage thresholds + colocate billing guidance (#2811)
- Move bunfig.toml to repo root with valid coverageThreshold syntax (line=80%, function=0 to avoid per-file false positives) - Add --coverage flag to CI test step - Delete packages/cli/bunfig.toml (superseded by root config) - Add tests for packages/shared (type-guards, parse, result) - Colocate billing config into each cloud directory (aws/billing.ts, gcp/billing.ts, hetzner/billing.ts, digitalocean/billing.ts) - Refactor billing-guidance.ts: BillingConfig interface replaces cloud-string-keyed Record maps - Bump CLI version to 0.25.1 Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
parent
aa4b2a23d6
commit
9ae3525030
17 changed files with 601 additions and 133 deletions
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
run: bun install
|
||||
|
||||
- name: Run mock tests
|
||||
run: bun test
|
||||
run: bun test --coverage
|
||||
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
[test]
|
||||
preload = ["./packages/cli/src/__tests__/preload.ts"]
|
||||
coverageThreshold = { line = 0.8, function = 0 }
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
[test]
|
||||
preload = ["./src/__tests__/preload.ts"]
|
||||
|
||||
[test.coverage]
|
||||
threshold = { line = 80, function = 90 }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
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 { gcpBilling } from "../gcp/billing";
|
||||
import { hetznerBilling } from "../hetzner/billing";
|
||||
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
|
||||
|
||||
// ── Mock deps (injected via DI, not mock.module) ──────────────────────────
|
||||
|
|
@ -21,34 +25,34 @@ function createMockDeps(): BillingGuidanceDeps {
|
|||
describe("isBillingError", () => {
|
||||
describe("hetzner", () => {
|
||||
it("matches insufficient_funds", () => {
|
||||
expect(isBillingError("hetzner", "insufficient funds")).toBe(true);
|
||||
expect(isBillingError("hetzner", "insufficient_funds")).toBe(true);
|
||||
expect(isBillingError(hetznerBilling, "insufficient funds")).toBe(true);
|
||||
expect(isBillingError(hetznerBilling, "insufficient_funds")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches payment method required", () => {
|
||||
expect(isBillingError("hetzner", "payment method required")).toBe(true);
|
||||
expect(isBillingError(hetznerBilling, "payment method required")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches account locked/blocked", () => {
|
||||
expect(isBillingError("hetzner", "account is locked")).toBe(true);
|
||||
expect(isBillingError("hetzner", "account blocked")).toBe(true);
|
||||
expect(isBillingError(hetznerBilling, "account is locked")).toBe(true);
|
||||
expect(isBillingError(hetznerBilling, "account blocked")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-billing errors", () => {
|
||||
expect(isBillingError("hetzner", "server limit reached")).toBe(false);
|
||||
expect(isBillingError("hetzner", "server type unavailable")).toBe(false);
|
||||
expect(isBillingError(hetznerBilling, "server limit reached")).toBe(false);
|
||||
expect(isBillingError(hetznerBilling, "server type unavailable")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("digitalocean", () => {
|
||||
it("matches billing-related errors", () => {
|
||||
expect(isBillingError("digitalocean", "insufficient funds")).toBe(true);
|
||||
expect(isBillingError("digitalocean", "payment required")).toBe(true);
|
||||
expect(isBillingError(digitaloceanBilling, "insufficient funds")).toBe(true);
|
||||
expect(isBillingError(digitaloceanBilling, "payment required")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-billing errors", () => {
|
||||
expect(isBillingError("digitalocean", "droplet limit reached")).toBe(false);
|
||||
expect(isBillingError("digitalocean", "region unavailable")).toBe(false);
|
||||
expect(isBillingError(digitaloceanBilling, "droplet limit reached")).toBe(false);
|
||||
expect(isBillingError(digitaloceanBilling, "region unavailable")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches billing error embedded in doApi thrown error message (regression #2395)", () => {
|
||||
|
|
@ -56,52 +60,57 @@ describe("isBillingError", () => {
|
|||
// The response body contains the billing message — isBillingError must detect it.
|
||||
const apiErr =
|
||||
'DigitalOcean API error 403 for POST /droplets: {"id":"forbidden","message":"A payment on file is required to create resources."}';
|
||||
expect(isBillingError("digitalocean", apiErr)).toBe(true);
|
||||
expect(isBillingError(digitaloceanBilling, apiErr)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-billing 403 in doApi error format", () => {
|
||||
const apiErr =
|
||||
'DigitalOcean API error 403 for POST /droplets: {"id":"forbidden","message":"Droplet limit exceeded for this account."}';
|
||||
expect(isBillingError("digitalocean", apiErr)).toBe(false);
|
||||
expect(isBillingError(digitaloceanBilling, apiErr)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aws", () => {
|
||||
it("matches activation/billing errors", () => {
|
||||
expect(isBillingError("aws", "account not activated")).toBe(true);
|
||||
expect(isBillingError("aws", "subscription required")).toBe(true);
|
||||
expect(isBillingError("aws", "not been enabled")).toBe(true);
|
||||
expect(isBillingError(awsBilling, "account not activated")).toBe(true);
|
||||
expect(isBillingError(awsBilling, "subscription required")).toBe(true);
|
||||
expect(isBillingError(awsBilling, "not been enabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-billing errors", () => {
|
||||
expect(isBillingError("aws", "instance limit reached")).toBe(false);
|
||||
expect(isBillingError("aws", "bundle unavailable")).toBe(false);
|
||||
expect(isBillingError(awsBilling, "instance limit reached")).toBe(false);
|
||||
expect(isBillingError(awsBilling, "bundle unavailable")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcp", () => {
|
||||
it("matches BILLING_DISABLED", () => {
|
||||
expect(isBillingError("gcp", "BILLING_DISABLED")).toBe(true);
|
||||
expect(isBillingError(gcpBilling, "BILLING_DISABLED")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches billing not enabled", () => {
|
||||
expect(isBillingError("gcp", "billing is not enabled")).toBe(true);
|
||||
expect(isBillingError("gcp", "billing disabled")).toBe(true);
|
||||
expect(isBillingError(gcpBilling, "billing is not enabled")).toBe(true);
|
||||
expect(isBillingError(gcpBilling, "billing disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches billing account errors", () => {
|
||||
expect(isBillingError("gcp", "no billing account linked")).toBe(true);
|
||||
expect(isBillingError(gcpBilling, "no billing account linked")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-billing errors", () => {
|
||||
expect(isBillingError("gcp", "quota exceeded")).toBe(false);
|
||||
expect(isBillingError("gcp", "machine type unavailable")).toBe(false);
|
||||
expect(isBillingError(gcpBilling, "quota exceeded")).toBe(false);
|
||||
expect(isBillingError(gcpBilling, "machine type unavailable")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown cloud", () => {
|
||||
it("returns false for unknown clouds", () => {
|
||||
expect(isBillingError("unknown", "billing error")).toBe(false);
|
||||
describe("empty config", () => {
|
||||
it("returns false for config with no error patterns", () => {
|
||||
const emptyConfig = {
|
||||
billingUrl: "",
|
||||
setupSteps: [],
|
||||
errorPatterns: [],
|
||||
};
|
||||
expect(isBillingError(emptyConfig, "billing error")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -122,21 +131,26 @@ describe("handleBillingError", () => {
|
|||
it("opens billing URL and returns true when user presses Enter", async () => {
|
||||
mockPrompt.mockImplementation(() => Promise.resolve(""));
|
||||
const deps = createMockDeps();
|
||||
const result = await handleBillingError("hetzner", deps);
|
||||
const result = await handleBillingError(hetznerBilling, deps);
|
||||
expect(result).toBe(true);
|
||||
expect(deps.openBrowser).toHaveBeenCalledWith("https://console.hetzner.cloud/");
|
||||
});
|
||||
|
||||
it("returns false when prompt throws (Ctrl+C)", async () => {
|
||||
mockPrompt.mockImplementation(() => Promise.reject(new Error("cancelled")));
|
||||
const result = await handleBillingError("digitalocean", createMockDeps());
|
||||
const result = await handleBillingError(digitaloceanBilling, createMockDeps());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("works for clouds without billing URL", async () => {
|
||||
it("works for config without billing URL", async () => {
|
||||
mockPrompt.mockImplementation(() => Promise.resolve(""));
|
||||
const deps = createMockDeps();
|
||||
const result = await handleBillingError("unknown", deps);
|
||||
const emptyConfig = {
|
||||
billingUrl: "",
|
||||
setupSteps: [],
|
||||
errorPatterns: [],
|
||||
};
|
||||
const result = await handleBillingError(emptyConfig, deps);
|
||||
expect(result).toBe(true);
|
||||
expect(deps.openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -157,7 +171,7 @@ describe("showNonBillingError", () => {
|
|||
const deps = createMockDeps();
|
||||
expect(() => {
|
||||
showNonBillingError(
|
||||
"hetzner",
|
||||
hetznerBilling,
|
||||
[
|
||||
"Server limit reached for your account",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
shellQuote,
|
||||
validateRegionName,
|
||||
} from "../shared/ui";
|
||||
import { awsBilling } from "./billing";
|
||||
|
||||
const DASHBOARD_URL = "https://lightsail.aws.amazon.com/";
|
||||
|
||||
|
|
@ -963,8 +964,8 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis
|
|||
const errMsg = getErrorMessage(createResult.error);
|
||||
logError(`Failed to create Lightsail instance: ${errMsg}`);
|
||||
|
||||
if (isBillingError("aws", errMsg)) {
|
||||
const shouldRetry = await handleBillingError("aws");
|
||||
if (isBillingError(awsBilling, errMsg)) {
|
||||
const shouldRetry = await handleBillingError(awsBilling);
|
||||
if (shouldRetry) {
|
||||
logStep("Retrying instance creation...");
|
||||
await lightsailCreateInstances(createParams);
|
||||
|
|
@ -984,7 +985,7 @@ export async function createInstance(name: string, tier?: CloudInitTier): Promis
|
|||
return await waitForInstance();
|
||||
}
|
||||
}
|
||||
showNonBillingError("aws", [
|
||||
showNonBillingError(awsBilling, [
|
||||
"Lightsail not enabled: visit https://lightsail.aws.amazon.com/ls/webapp/home to activate",
|
||||
"Instance limit reached for your account",
|
||||
"Bundle unavailable in region",
|
||||
|
|
|
|||
18
packages/cli/src/aws/billing.ts
Normal file
18
packages/cli/src/aws/billing.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { BillingConfig } from "../shared/billing-guidance";
|
||||
|
||||
export const awsBilling: BillingConfig = {
|
||||
billingUrl: "https://lightsail.aws.amazon.com/",
|
||||
setupSteps: [
|
||||
"1. Open the AWS Lightsail console",
|
||||
"2. Complete account activation if prompted",
|
||||
"3. Add a payment method in AWS Billing",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
errorPatterns: [
|
||||
/billing[_ ]?disabled/i,
|
||||
/not[_ ](?:been[_ ])?(?:activated|enabled)/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
|
||||
/subscription[_ ]required/i,
|
||||
],
|
||||
};
|
||||
18
packages/cli/src/digitalocean/billing.ts
Normal file
18
packages/cli/src/digitalocean/billing.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { BillingConfig } from "../shared/billing-guidance";
|
||||
|
||||
export const digitaloceanBilling: BillingConfig = {
|
||||
billingUrl: "https://cloud.digitalocean.com/account/billing",
|
||||
setupSteps: [
|
||||
"1. Open DigitalOcean Billing Settings",
|
||||
"2. Add a credit card or PayPal account",
|
||||
"3. Verify your email address if prompted",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
errorPatterns: [
|
||||
/insufficient[_ ]funds/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
|
||||
/billing/i,
|
||||
/payment/i,
|
||||
],
|
||||
};
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
validateRegionName,
|
||||
validateServerName,
|
||||
} from "../shared/ui";
|
||||
import { digitaloceanBilling } from "./billing";
|
||||
|
||||
const DO_API_BASE = "https://api.digitalocean.com/v2";
|
||||
const DO_DASHBOARD_URL = "https://cloud.digitalocean.com/droplets";
|
||||
|
|
@ -414,7 +415,7 @@ export async function checkAccountStatus(): Promise<void> {
|
|||
// Re-check with new account
|
||||
return;
|
||||
}
|
||||
const shouldRetry = await handleBillingError("digitalocean");
|
||||
const shouldRetry = await handleBillingError(digitaloceanBilling);
|
||||
if (!shouldRetry) {
|
||||
throw new Error("DigitalOcean account is locked");
|
||||
}
|
||||
|
|
@ -1056,14 +1057,14 @@ export async function createServer(
|
|||
const errMsg = createApiResult.error.message;
|
||||
logError(`Failed to create DigitalOcean droplet: ${errMsg}`);
|
||||
|
||||
if (isBillingError("digitalocean", errMsg)) {
|
||||
if (isBillingError(digitaloceanBilling, errMsg)) {
|
||||
// Offer account switch before billing guidance
|
||||
const switched = await promptSwitchAccount();
|
||||
if (switched) {
|
||||
logStep("Retrying droplet creation with new account...");
|
||||
return createServer(name, tier, dropletSize, region, imageOverride);
|
||||
}
|
||||
const shouldRetry = await handleBillingError("digitalocean");
|
||||
const shouldRetry = await handleBillingError(digitaloceanBilling);
|
||||
if (shouldRetry) {
|
||||
logStep("Retrying droplet creation...");
|
||||
const retryText = await doApi("POST", "/droplets", body);
|
||||
|
|
@ -1083,7 +1084,7 @@ export async function createServer(
|
|||
logError(`Retry failed: ${String(retryData?.message || "Unknown error")}`);
|
||||
}
|
||||
} else {
|
||||
showNonBillingError("digitalocean", [
|
||||
showNonBillingError(digitaloceanBilling, [
|
||||
"Region/size unavailable (try different DO_REGION or DO_DROPLET_SIZE)",
|
||||
"Droplet limit reached (check account limits)",
|
||||
]);
|
||||
|
|
@ -1101,7 +1102,7 @@ export async function createServer(
|
|||
|
||||
if (!createData?.droplet?.id) {
|
||||
logError("Failed to create DigitalOcean droplet: unexpected API response");
|
||||
showNonBillingError("digitalocean", [
|
||||
showNonBillingError(digitaloceanBilling, [
|
||||
"Region/size unavailable (try different DO_REGION or DO_DROPLET_SIZE)",
|
||||
"Droplet limit reached (check account limits)",
|
||||
]);
|
||||
|
|
|
|||
18
packages/cli/src/gcp/billing.ts
Normal file
18
packages/cli/src/gcp/billing.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { BillingConfig } from "../shared/billing-guidance";
|
||||
|
||||
export const gcpBilling: BillingConfig = {
|
||||
billingUrl: "https://console.cloud.google.com/billing",
|
||||
setupSteps: [
|
||||
"1. Open the Google Cloud Billing page",
|
||||
"2. Link a billing account to your project",
|
||||
"3. Enable the Compute Engine API",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
errorPatterns: [
|
||||
/billing[_ ]?(?:is[_ ])?(?:not[_ ])?(?:enabled|disabled)/i,
|
||||
/billing[_ ]account/i,
|
||||
/BILLING_DISABLED/,
|
||||
/project.*has.*no.*billing/i,
|
||||
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
|
||||
],
|
||||
};
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
selectFromList,
|
||||
shellQuote,
|
||||
} from "../shared/ui";
|
||||
import { gcpBilling } from "./billing";
|
||||
|
||||
const DASHBOARD_URL = "https://console.cloud.google.com/compute/instances";
|
||||
|
||||
|
|
@ -567,7 +568,7 @@ export async function checkBillingEnabled(): Promise<void> {
|
|||
const output = result.stdout.trim().toLowerCase();
|
||||
if (output === "false") {
|
||||
logWarn(`Billing is not enabled for project '${_state.project}'.`);
|
||||
const shouldRetry = await handleBillingError("gcp");
|
||||
const shouldRetry = await handleBillingError(gcpBilling);
|
||||
if (!shouldRetry) {
|
||||
throw new Error("GCP billing not enabled");
|
||||
}
|
||||
|
|
@ -794,8 +795,8 @@ export async function createInstance(
|
|||
logError(`gcloud error: ${result.stderr}`);
|
||||
}
|
||||
|
||||
if (isBillingError("gcp", errMsg)) {
|
||||
const shouldRetry = await handleBillingError("gcp");
|
||||
if (isBillingError(gcpBilling, errMsg)) {
|
||||
const shouldRetry = await handleBillingError(gcpBilling);
|
||||
if (shouldRetry) {
|
||||
logStep("Retrying instance creation...");
|
||||
const retryResult = await gcloud(args);
|
||||
|
|
@ -841,7 +842,7 @@ export async function createInstance(
|
|||
throw new Error("Instance creation failed");
|
||||
}
|
||||
} else {
|
||||
showNonBillingError("gcp", [
|
||||
showNonBillingError(gcpBilling, [
|
||||
"Instance quota exceeded (try different GCP_ZONE)",
|
||||
"Machine type unavailable (try different GCP_MACHINE_TYPE or GCP_ZONE)",
|
||||
]);
|
||||
|
|
|
|||
17
packages/cli/src/hetzner/billing.ts
Normal file
17
packages/cli/src/hetzner/billing.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { BillingConfig } from "../shared/billing-guidance";
|
||||
|
||||
export const hetznerBilling: BillingConfig = {
|
||||
billingUrl: "https://console.hetzner.cloud/",
|
||||
setupSteps: [
|
||||
"1. Open the Hetzner Cloud Console",
|
||||
"2. Go to Billing → Payment Methods",
|
||||
"3. Add a credit card or PayPal account",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
errorPatterns: [
|
||||
/insufficient[_ ]funds/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
|
||||
/billing/i,
|
||||
],
|
||||
};
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
shellQuote,
|
||||
validateRegionName,
|
||||
} from "../shared/ui";
|
||||
import { hetznerBilling } from "./billing";
|
||||
|
||||
const HETZNER_API_BASE = "https://api.hetzner.cloud/v1";
|
||||
const HETZNER_DASHBOARD_URL = "https://console.hetzner.cloud/";
|
||||
|
|
@ -606,8 +607,8 @@ export async function createServer(
|
|||
|
||||
logError(`Failed to create Hetzner server: ${errMsg}`);
|
||||
|
||||
if (isBillingError("hetzner", errMsg)) {
|
||||
const shouldRetry = await handleBillingError("hetzner");
|
||||
if (isBillingError(hetznerBilling, errMsg)) {
|
||||
const shouldRetry = await handleBillingError(hetznerBilling);
|
||||
if (shouldRetry) {
|
||||
logStep("Retrying server creation...");
|
||||
const retryResp = await hetznerApi("POST", "/servers", body);
|
||||
|
|
@ -633,7 +634,7 @@ export async function createServer(
|
|||
logError(`Retry failed: ${retryErr}`);
|
||||
}
|
||||
} else {
|
||||
showNonBillingError("hetzner", [
|
||||
showNonBillingError(hetznerBilling, [
|
||||
"Server type or location unavailable",
|
||||
"Server limit reached for your account",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,83 +3,20 @@
|
|||
import { asyncTryCatch, unwrapOr } from "./result.js";
|
||||
import { logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
|
||||
|
||||
// ─── Billing URLs per cloud ─────────────────────────────────────────────────
|
||||
// ─── BillingConfig interface ────────────────────────────────────────────────
|
||||
|
||||
const BILLING_URLS: Record<string, string> = {
|
||||
hetzner: "https://console.hetzner.cloud/",
|
||||
digitalocean: "https://cloud.digitalocean.com/account/billing",
|
||||
aws: "https://lightsail.aws.amazon.com/",
|
||||
gcp: "https://console.cloud.google.com/billing",
|
||||
};
|
||||
|
||||
// ─── Setup steps per cloud ──────────────────────────────────────────────────
|
||||
|
||||
const SETUP_STEPS: Record<string, string[]> = {
|
||||
hetzner: [
|
||||
"1. Open the Hetzner Cloud Console",
|
||||
"2. Go to Billing → Payment Methods",
|
||||
"3. Add a credit card or PayPal account",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
digitalocean: [
|
||||
"1. Open DigitalOcean Billing Settings",
|
||||
"2. Add a credit card or PayPal account",
|
||||
"3. Verify your email address if prompted",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
aws: [
|
||||
"1. Open the AWS Lightsail console",
|
||||
"2. Complete account activation if prompted",
|
||||
"3. Add a payment method in AWS Billing",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
gcp: [
|
||||
"1. Open the Google Cloud Billing page",
|
||||
"2. Link a billing account to your project",
|
||||
"3. Enable the Compute Engine API",
|
||||
"4. Return here and press Enter to retry",
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Error patterns per cloud ───────────────────────────────────────────────
|
||||
|
||||
const ERROR_PATTERNS: Record<string, RegExp[]> = {
|
||||
hetzner: [
|
||||
/insufficient[_ ]funds/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
|
||||
/billing/i,
|
||||
],
|
||||
digitalocean: [
|
||||
/insufficient[_ ]funds/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:locked|blocked|suspended)/i,
|
||||
/billing/i,
|
||||
/payment/i,
|
||||
],
|
||||
aws: [
|
||||
/billing[_ ]?disabled/i,
|
||||
/not[_ ](?:been[_ ])?(?:activated|enabled)/i,
|
||||
/payment[_ ]method[_ ]required/i,
|
||||
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
|
||||
/subscription[_ ]required/i,
|
||||
],
|
||||
gcp: [
|
||||
/billing[_ ]?(?:is[_ ])?(?:not[_ ])?(?:enabled|disabled)/i,
|
||||
/billing[_ ]account/i,
|
||||
/BILLING_DISABLED/,
|
||||
/project.*has.*no.*billing/i,
|
||||
/account[_ ](?:is[_ ])?(?:suspended|closed)/i,
|
||||
],
|
||||
};
|
||||
export interface BillingConfig {
|
||||
billingUrl: string;
|
||||
setupSteps: string[];
|
||||
errorPatterns: RegExp[];
|
||||
}
|
||||
|
||||
/** Check if an error message matches known billing error patterns for a cloud. */
|
||||
export function isBillingError(cloud: string, errorMsg: string): boolean {
|
||||
const patterns = ERROR_PATTERNS[cloud];
|
||||
if (!patterns) {
|
||||
export function isBillingError(config: BillingConfig, errorMsg: string): boolean {
|
||||
if (!config.errorPatterns || config.errorPatterns.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return patterns.some((p) => p.test(errorMsg));
|
||||
return config.errorPatterns.some((p) => p.test(errorMsg));
|
||||
}
|
||||
|
||||
/** Dependencies for billing-guidance functions (injectable for testing). */
|
||||
|
|
@ -103,9 +40,12 @@ const defaultDeps: BillingGuidanceDeps = {
|
|||
* Show billing guidance, open the billing page, and prompt user to retry.
|
||||
* Returns true if user wants to retry, false otherwise.
|
||||
*/
|
||||
export async function handleBillingError(cloud: string, deps: BillingGuidanceDeps = defaultDeps): Promise<boolean> {
|
||||
const billingUrl = BILLING_URLS[cloud];
|
||||
const steps = SETUP_STEPS[cloud] || [];
|
||||
export async function handleBillingError(
|
||||
config: BillingConfig,
|
||||
deps: BillingGuidanceDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const billingUrl = config.billingUrl;
|
||||
const steps = config.setupSteps;
|
||||
|
||||
process.stderr.write("\n");
|
||||
deps.logWarn("Your account needs a payment method to create servers.");
|
||||
|
|
@ -137,7 +77,7 @@ export async function handleBillingError(cloud: string, deps: BillingGuidanceDep
|
|||
* Show non-billing error guidance with cloud-specific causes and dashboard link.
|
||||
*/
|
||||
export function showNonBillingError(
|
||||
cloud: string,
|
||||
config: BillingConfig,
|
||||
causes: string[],
|
||||
deps: Pick<BillingGuidanceDeps, "logInfo" | "logWarn"> = defaultDeps,
|
||||
): void {
|
||||
|
|
@ -147,8 +87,7 @@ export function showNonBillingError(
|
|||
deps.logWarn(` - ${cause}`);
|
||||
}
|
||||
}
|
||||
const billingUrl = BILLING_URLS[cloud];
|
||||
if (billingUrl) {
|
||||
deps.logInfo(`Dashboard: ${billingUrl}`);
|
||||
if (config.billingUrl) {
|
||||
deps.logInfo(`Dashboard: ${config.billingUrl}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
packages/shared/src/__tests__/parse.test.ts
Normal file
58
packages/shared/src/__tests__/parse.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import * as v from "valibot";
|
||||
import { parseJsonWith, parseJsonObj } from "../parse";
|
||||
|
||||
describe("parseJsonWith", () => {
|
||||
const UserSchema = v.object({ id: v.number(), name: v.string() });
|
||||
|
||||
it("returns validated data for valid JSON matching schema", () => {
|
||||
const result = parseJsonWith('{"id": 1, "name": "Alice"}', UserSchema);
|
||||
expect(result).toEqual({ id: 1, name: "Alice" });
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(parseJsonWith("not json", UserSchema)).toBeNull();
|
||||
expect(parseJsonWith("{broken", UserSchema)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for valid JSON that does not match schema", () => {
|
||||
expect(parseJsonWith('{"id": "not-a-number", "name": "Alice"}', UserSchema)).toBeNull();
|
||||
expect(parseJsonWith('{"wrong": "shape"}', UserSchema)).toBeNull();
|
||||
});
|
||||
|
||||
it("works with simple schemas", () => {
|
||||
const StringSchema = v.string();
|
||||
expect(parseJsonWith('"hello"', StringSchema)).toBe("hello");
|
||||
expect(parseJsonWith("42", StringSchema)).toBeNull();
|
||||
});
|
||||
|
||||
it("works with array schemas", () => {
|
||||
const ArraySchema = v.array(v.number());
|
||||
expect(parseJsonWith("[1, 2, 3]", ArraySchema)).toEqual([1, 2, 3]);
|
||||
expect(parseJsonWith('["a", "b"]', ArraySchema)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonObj", () => {
|
||||
it("returns Record for valid JSON objects", () => {
|
||||
expect(parseJsonObj('{"a": 1}')).toEqual({ a: 1 });
|
||||
expect(parseJsonObj('{"nested": {"b": 2}}')).toEqual({ nested: { b: 2 } });
|
||||
});
|
||||
|
||||
it("returns null for JSON arrays", () => {
|
||||
expect(parseJsonObj("[1, 2]")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for JSON primitives", () => {
|
||||
expect(parseJsonObj('"str"')).toBeNull();
|
||||
expect(parseJsonObj("42")).toBeNull();
|
||||
expect(parseJsonObj("true")).toBeNull();
|
||||
expect(parseJsonObj("null")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(parseJsonObj("not json")).toBeNull();
|
||||
expect(parseJsonObj("{broken")).toBeNull();
|
||||
expect(parseJsonObj("")).toBeNull();
|
||||
});
|
||||
});
|
||||
247
packages/shared/src/__tests__/result.test.ts
Normal file
247
packages/shared/src/__tests__/result.test.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
Ok,
|
||||
Err,
|
||||
tryCatch,
|
||||
asyncTryCatch,
|
||||
tryCatchIf,
|
||||
asyncTryCatchIf,
|
||||
unwrapOr,
|
||||
mapResult,
|
||||
isFileError,
|
||||
isNetworkError,
|
||||
isOperationalError,
|
||||
} from "../result";
|
||||
|
||||
describe("Ok", () => {
|
||||
it("creates an Ok result", () => {
|
||||
expect(Ok(42)).toEqual({ ok: true, data: 42 });
|
||||
expect(Ok("hello")).toEqual({ ok: true, data: "hello" });
|
||||
expect(Ok(null)).toEqual({ ok: true, data: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Err", () => {
|
||||
it("creates an Err result", () => {
|
||||
const error = new Error("fail");
|
||||
const result = Err(error);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryCatch", () => {
|
||||
it("returns Ok for successful functions", () => {
|
||||
const result = tryCatch(() => 42);
|
||||
expect(result).toEqual({ ok: true, data: 42 });
|
||||
});
|
||||
|
||||
it("returns Err for thrown Error instances", () => {
|
||||
const result = tryCatch(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("boom");
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error throws into Error", () => {
|
||||
const result = tryCatch(() => {
|
||||
throw "string error";
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error.message).toBe("string error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("asyncTryCatch", () => {
|
||||
it("returns Ok for successful async functions", async () => {
|
||||
const result = await asyncTryCatch(async () => 42);
|
||||
expect(result).toEqual({ ok: true, data: 42 });
|
||||
});
|
||||
|
||||
it("returns Err for rejected promises", async () => {
|
||||
const result = await asyncTryCatch(async () => {
|
||||
throw new Error("async boom");
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("async boom");
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error throws into Error", async () => {
|
||||
const result = await asyncTryCatch(async () => {
|
||||
throw 404;
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error.message).toBe("404");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryCatchIf", () => {
|
||||
it("returns Ok for successful functions", () => {
|
||||
const result = tryCatchIf(() => true, () => 42);
|
||||
expect(result).toEqual({ ok: true, data: 42 });
|
||||
});
|
||||
|
||||
it("catches errors matching the guard", () => {
|
||||
const guard = (err: Error) => err.message === "expected";
|
||||
const result = tryCatchIf(guard, () => {
|
||||
throw new Error("expected");
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("expected");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-throws errors not matching the guard", () => {
|
||||
const guard = (err: Error) => err.message === "expected";
|
||||
expect(() =>
|
||||
tryCatchIf(guard, () => {
|
||||
throw new Error("unexpected");
|
||||
}),
|
||||
).toThrow("unexpected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("asyncTryCatchIf", () => {
|
||||
it("returns Ok for successful async functions", async () => {
|
||||
const result = await asyncTryCatchIf(() => true, async () => 42);
|
||||
expect(result).toEqual({ ok: true, data: 42 });
|
||||
});
|
||||
|
||||
it("catches errors matching the guard", async () => {
|
||||
const guard = (err: Error) => err.message === "expected";
|
||||
const result = await asyncTryCatchIf(guard, async () => {
|
||||
throw new Error("expected");
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("expected");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-throws errors not matching the guard", async () => {
|
||||
const guard = (err: Error) => err.message === "expected";
|
||||
expect(
|
||||
asyncTryCatchIf(guard, async () => {
|
||||
throw new Error("unexpected");
|
||||
}),
|
||||
).rejects.toThrow("unexpected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapOr", () => {
|
||||
it("returns data for Ok results", () => {
|
||||
expect(unwrapOr(Ok(42), 0)).toBe(42);
|
||||
expect(unwrapOr(Ok("hello"), "fallback")).toBe("hello");
|
||||
});
|
||||
|
||||
it("returns fallback for Err results", () => {
|
||||
expect(unwrapOr(Err(new Error("fail")), 0)).toBe(0);
|
||||
expect(unwrapOr(Err(new Error("fail")), "fallback")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapResult", () => {
|
||||
it("transforms Ok data", () => {
|
||||
const result = mapResult(Ok(2), (n) => n * 3);
|
||||
expect(result).toEqual({ ok: true, data: 6 });
|
||||
});
|
||||
|
||||
it("passes Err through unchanged", () => {
|
||||
const error = new Error("fail");
|
||||
const result = mapResult(Err<number>(error), (n) => n * 3);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFileError", () => {
|
||||
it("returns true for file error codes", () => {
|
||||
for (const code of ["ENOENT", "EACCES", "EISDIR", "ENOSPC", "EPERM", "ENOTDIR"]) {
|
||||
const err = Object.assign(new Error("fail"), { code });
|
||||
expect(isFileError(err)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for non-file error codes", () => {
|
||||
const err = Object.assign(new Error("fail"), { code: "ECONNREFUSED" });
|
||||
expect(isFileError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for errors without code", () => {
|
||||
expect(isFileError(new Error("fail"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNetworkError", () => {
|
||||
it("returns true for network error codes", () => {
|
||||
for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EPIPE", "EAI_AGAIN"]) {
|
||||
const err = Object.assign(new Error("fail"), { code });
|
||||
expect(isNetworkError(err)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for AbortError", () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
expect(isNetworkError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for TimeoutError", () => {
|
||||
const err = new Error("timeout");
|
||||
err.name = "TimeoutError";
|
||||
expect(isNetworkError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for TypeError with fetch/network/socket message", () => {
|
||||
const err = new TypeError("fetch failed to connect");
|
||||
expect(isNetworkError(err)).toBe(true);
|
||||
|
||||
const err2 = new TypeError("network connection lost");
|
||||
expect(isNetworkError(err2)).toBe(true);
|
||||
|
||||
const err3 = new TypeError("socket hang up");
|
||||
expect(isNetworkError(err3)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for errors with fetch failed message", () => {
|
||||
const err = new Error("fetch failed");
|
||||
expect(isNetworkError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", () => {
|
||||
expect(isNetworkError(new Error("something else"))).toBe(false);
|
||||
expect(isNetworkError(new TypeError("Cannot read property"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOperationalError", () => {
|
||||
it("returns true for file errors", () => {
|
||||
const err = Object.assign(new Error("fail"), { code: "ENOENT" });
|
||||
expect(isOperationalError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for network errors", () => {
|
||||
const err = Object.assign(new Error("fail"), { code: "ECONNREFUSED" });
|
||||
expect(isOperationalError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-operational errors", () => {
|
||||
expect(isOperationalError(new Error("random"))).toBe(false);
|
||||
});
|
||||
});
|
||||
139
packages/shared/src/__tests__/type-guards.test.ts
Normal file
139
packages/shared/src/__tests__/type-guards.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
isPlainObject,
|
||||
isString,
|
||||
isNumber,
|
||||
hasStatus,
|
||||
getErrorMessage,
|
||||
toRecord,
|
||||
toObjectArray,
|
||||
} from "../type-guards";
|
||||
|
||||
describe("isPlainObject", () => {
|
||||
it("returns true for plain objects", () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||
expect(isPlainObject({ nested: { b: 2 } })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for arrays", () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject([1, 2, 3])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for primitives", () => {
|
||||
expect(isPlainObject("str")).toBe(false);
|
||||
expect(isPlainObject(42)).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isString", () => {
|
||||
it("returns true for strings", () => {
|
||||
expect(isString("")).toBe(true);
|
||||
expect(isString("hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-strings", () => {
|
||||
expect(isString(42)).toBe(false);
|
||||
expect(isString(null)).toBe(false);
|
||||
expect(isString(undefined)).toBe(false);
|
||||
expect(isString({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNumber", () => {
|
||||
it("returns true for numbers", () => {
|
||||
expect(isNumber(0)).toBe(true);
|
||||
expect(isNumber(42)).toBe(true);
|
||||
expect(isNumber(-1)).toBe(true);
|
||||
expect(isNumber(NaN)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-numbers", () => {
|
||||
expect(isNumber("42")).toBe(false);
|
||||
expect(isNumber(null)).toBe(false);
|
||||
expect(isNumber(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasStatus", () => {
|
||||
it("returns true for objects with numeric status", () => {
|
||||
expect(hasStatus({ status: 200 })).toBe(true);
|
||||
expect(hasStatus({ status: 0 })).toBe(true);
|
||||
expect(hasStatus({ status: 500, other: "field" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for objects without numeric status", () => {
|
||||
expect(hasStatus({ status: "ok" })).toBe(false);
|
||||
expect(hasStatus({})).toBe(false);
|
||||
expect(hasStatus({ status: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-objects", () => {
|
||||
expect(hasStatus(null)).toBe(false);
|
||||
expect(hasStatus(undefined)).toBe(false);
|
||||
expect(hasStatus("string")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("returns .message for Error-like objects", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom");
|
||||
expect(getErrorMessage({ message: "custom error" })).toBe("custom error");
|
||||
});
|
||||
|
||||
it("returns String(err) for non-Error values", () => {
|
||||
expect(getErrorMessage("string error")).toBe("string error");
|
||||
expect(getErrorMessage(42)).toBe("42");
|
||||
expect(getErrorMessage(null)).toBe("null");
|
||||
expect(getErrorMessage(undefined)).toBe("undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRecord", () => {
|
||||
it("returns plain objects as-is", () => {
|
||||
const obj = { a: 1 };
|
||||
expect(toRecord(obj)).toBe(obj);
|
||||
expect(toRecord({})).toEqual({});
|
||||
});
|
||||
|
||||
it("returns null for non-plain-objects", () => {
|
||||
expect(toRecord(null)).toBeNull();
|
||||
expect(toRecord(undefined)).toBeNull();
|
||||
expect(toRecord([1, 2])).toBeNull();
|
||||
expect(toRecord("str")).toBeNull();
|
||||
expect(toRecord(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toObjectArray", () => {
|
||||
it("filters non-plain-object items from arrays", () => {
|
||||
const result = toObjectArray([{ a: 1 }, "skip", null, { b: 2 }, 42]);
|
||||
expect(result).toEqual([{ a: 1 }, { b: 2 }]);
|
||||
});
|
||||
|
||||
it("returns all items if all are plain objects", () => {
|
||||
expect(toObjectArray([{ x: 1 }, { y: 2 }])).toEqual([{ x: 1 }, { y: 2 }]);
|
||||
});
|
||||
|
||||
it("returns empty array for non-arrays", () => {
|
||||
expect(toObjectArray("not array")).toEqual([]);
|
||||
expect(toObjectArray(null)).toEqual([]);
|
||||
expect(toObjectArray(undefined)).toEqual([]);
|
||||
expect(toObjectArray(42)).toEqual([]);
|
||||
expect(toObjectArray({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for array of only non-objects", () => {
|
||||
expect(toObjectArray([1, "two", null, true])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue