Skip to content
7 changes: 6 additions & 1 deletion packages/cli/skills/deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,13 @@ it in a shell loop (or a hardcoded `sleep`) hammers the API and trips server
rate limits. `status --wait` blocks using the CLI's internal watch loop, which
paces requests and backs off on `429/502/503/504`.

`--wait` only blocks while the app is still transitioning (`Deploying`,
`Upgrading`, `Resuming`, `Stopping`, `Terminating`, `Created`). If the app has
already settled (`Running`, `Stopped`, `Terminated`, `Suspended`, `Failed`), it
returns immediately — so it is always safe to call, even after the app is up.

```bash
# Blocks until the app reaches a terminal status or the timeout elapses.
# Blocks only if still transitioning; otherwise returns the settled status now.
ecloud compute app status <app-id> --wait

# Bound the wait (default 600s) and/or get machine-readable output:
Expand Down
32 changes: 31 additions & 1 deletion packages/cli/src/commands/billing/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ describe("ecloud billing status — top-up hint", () => {
let mockBilling: {
address: string;
getStatus: ReturnType<typeof vi.fn>;
getAccountCredits: ReturnType<typeof vi.fn>;
getTopUpInfo: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
Expand All @@ -32,6 +34,13 @@ describe("ecloud billing status — top-up hint", () => {
mockBilling = {
address: "0xabcdef1234567890abcdef1234567890abcdef12",
getStatus: vi.fn(),
getAccountCredits: vi.fn().mockResolvedValue({
remainingCredits: 0,
permanentCredits: 0,
promotionalCredits: 0,
nextPromotionalCreditExpiry: 0,
}),
getTopUpInfo: vi.fn().mockResolvedValue({ usdcBalance: 0n }),
};
(createBillingClient as ReturnType<typeof vi.fn>).mockResolvedValue(mockBilling);
});
Expand Down Expand Up @@ -133,8 +142,29 @@ describe("ecloud billing status — top-up hint", () => {
{ "private-key": "0xgood", environment: "sepolia" },
);

expect(output.join("\n")).toMatch(/Wallet ETH.*1 ETH/);
expect(output.join("\n")).toMatch(/Wallet \(sepolia\):/);
expect(output.join("\n")).toMatch(/ETH:\s+1 ETH/);
expect(warnOutput).toHaveLength(0);
});

it("renders the promotional/paid credit split", async () => {
(createViemClients as ReturnType<typeof vi.fn>).mockReturnValue({
publicClient: { getBalance: vi.fn().mockResolvedValue(0n) },
address: "0xabcdef1234567890abcdef1234567890abcdef12",
});
mockBilling.getAccountCredits.mockResolvedValue({
remainingCredits: 25,
permanentCredits: 0,
promotionalCredits: 25,
nextPromotionalCreditExpiry: 0,
});
const output = await runStatusCommand(
{ subscriptionStatus: "active", productId: "compute" },
{ "private-key": "0xgood", environment: "sepolia" },
);
const out = output.join("\n");
expect(out).toContain("Credits (Stripe):");
expect(out).toContain("Promotional:");
});
});
});
96 changes: 45 additions & 51 deletions packages/cli/src/commands/billing/status.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Command, Flags } from "@oclif/core";
import { createBillingClient } from "../../client";
import { commonFlags } from "../../flags";
import { getEnvironmentConfig } from "@layr-labs/ecloud-sdk";
import { getEnvironmentConfig, type AccountCreditsResponse } from "@layr-labs/ecloud-sdk";
import { createViemClients } from "../../utils/viemClients";
import { errorMessage } from "../../utils/exitCodes";
import { formatEther } from "viem";
import { formatUnits } from "viem";
import chalk from "chalk";
import { withTelemetry } from "../../telemetry";
import { formatFundsBlock, formatLineItem, formatEthDisplay } from "../../utils/billingFormat";

export default class BillingStatus extends Command {
static description = "Show subscription status";
Expand All @@ -31,9 +32,6 @@ export default class BillingStatus extends Command {
productId: flags.product as "compute",
});

const formatExpiry = (timestamp?: number) =>
timestamp ? ` (expires ${new Date(timestamp * 1000).toLocaleDateString()})` : "";

// Format status with appropriate color and symbol
const formatStatus = (status: string) => {
switch (status) {
Expand Down Expand Up @@ -63,32 +61,6 @@ export default class BillingStatus extends Command {
this.log(`\n${chalk.bold("Subscription Status:")}`);
this.log(` Wallet: ${billing.address}`);

// Show the wallet's on-chain ETH so the credit-vs-gas gap is visible
// before a deploy: compute credits do NOT pay on-chain gas.
// Best-effort — never fail `billing status` if the balance read fails.
try {
const privateKey = flags["private-key"];
if (privateKey) {
const environment = flags.environment;
const environmentConfig = getEnvironmentConfig(environment);
const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL;
const { publicClient, address } = createViemClients({ privateKey, rpcUrl, environment });
const balanceWei = await publicClient.getBalance({ address });
const eth = formatEther(balanceWei);
const note =
balanceWei === BigInt(0)
? chalk.yellow(" (fund with ETH to pay deploy/upgrade gas)")
: "";
this.log(` Wallet ETH (${environment}): ${chalk.cyan(`${eth} ETH`)}${note}`);
}
} catch (err) {
// Best-effort: the ETH line is informational, so a failure here must
// not abort `billing status`. But surface the reason rather than
// swallowing it — a malformed --private-key or an unreachable RPC is
// worth telling the user about.
this.warn(`Could not read wallet ETH balance: ${errorMessage(err)}`);
}

this.log(` Status: ${formatStatus(result.subscriptionStatus)}`);
this.log(` Product: ${result.productId}`);

Expand All @@ -102,30 +74,52 @@ export default class BillingStatus extends Command {
// Display line items if available
if (result.lineItems && result.lineItems.length > 0) {
this.log(`\n${chalk.bold(" Line Items:")}`);
const productLabel = `${flags.product.charAt(0).toUpperCase()}${flags.product.slice(1)}`;
for (const item of result.lineItems) {
const product = `${flags.product.charAt(0).toUpperCase()}${flags.product.slice(1)}`;
const isChainSpecific = item.description.match(/\b(sepolia|mainnet)\b/i);
if (isChainSpecific) {
const chain = item.description.toLowerCase().includes("sepolia")
? "Sepolia"
: "Mainnet";
this.log(
` • ${product} (${chain}): $${item.subtotal.toFixed(2)} (${item.quantity} vCPU hours × $${item.price.toFixed(3)}/vCPU hour)`,
);
} else {
const sku = item.description.split(" ").slice(-2).join(" ") || "Unknown";
this.log(
` • ${product} (${sku}): $${item.subtotal.toFixed(2)} (${item.quantity} hours × $${item.price.toFixed(3)}/hour)`,
);
}
this.log(formatLineItem(item, productLabel));
}
}

// Display remaining credits
const credits = result.remainingCredits ?? 0;
this.log(
` Credits: ${chalk.cyan(`$${credits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`,
);
// --- Funds: Stripe credit split + on-chain wallet (each best-effort) ---
let credits: AccountCreditsResponse | undefined;
try {
credits = await billing.getAccountCredits();
} catch (err) {
this.warn(`Could not read credit split: ${errorMessage(err)}`);
}

let walletEthFormatted: string | undefined;
let walletUsdcFormatted: string | undefined;
const privateKey = flags["private-key"];
if (privateKey) {
const environment = flags.environment;
const environmentConfig = getEnvironmentConfig(environment);
const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL;
try {
const { publicClient, address } = createViemClients({ privateKey, rpcUrl, environment });
const balanceWei = await publicClient.getBalance({ address });
walletEthFormatted = formatEthDisplay(balanceWei);
} catch (err) {
this.warn(`Could not read wallet ETH balance: ${errorMessage(err)}`);
}
try {
const { usdcBalance } = await billing.getTopUpInfo();
walletUsdcFormatted = formatUnits(usdcBalance, 6);
} catch (err) {
this.warn(`Could not read wallet USDC balance: ${errorMessage(err)}`);
}
}

for (const line of formatFundsBlock({
env: flags.environment,
credits,
remainingCreditsFallback: result.remainingCredits,
nextCreditExpiryFallback: result.nextCreditExpiry,
walletEthFormatted,
walletUsdcFormatted,
})) {
this.log(line);
}

// Display cancellation information
if (result.cancelAtPeriodEnd) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Address } from "viem";

const APP_A = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09" as Address;

const findLiveAppByName = vi.fn();
vi.mock("../../../../utils/appCollision", () => ({
findLiveAppByName: (...a: unknown[]) => findLiveAppByName(...(a as [])),
}));

import { assertNoLiveNameCollision } from "../deploy";

function makeCtx() {
const errors: { message: string; exit?: number }[] = [];
const warns: string[] = [];
return {
errors,
warns,
cmd: {
error: (message: string, opts?: { exit?: number }) => {
errors.push({ message, exit: opts?.exit });
throw new Error(message); // oclif's this.error throws
},
warn: (message: string) => warns.push(message),
},
};
}

const ARGS = { environment: "sepolia-dev", privateKey: "0xkey", rpcUrl: "https://rpc.test", name: "foo" };

describe("assertNoLiveNameCollision", () => {
beforeEach(() => vi.clearAllMocks());

it("errors with exit 2 and references upgrade when a live same-named app exists", async () => {
findLiveAppByName.mockResolvedValue(APP_A);
const { cmd, errors } = makeCtx();

await expect(
assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false }),
).rejects.toThrow();

expect(errors).toHaveLength(1);
expect(errors[0].exit).toBe(2);
expect(errors[0].message).toContain(APP_A);
expect(errors[0].message).toContain("upgrade");
expect(errors[0].message).toContain("--force-new");
});

it("skips the check entirely when --force-new is set", async () => {
const { cmd } = makeCtx();
await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: true });
expect(findLiveAppByName).not.toHaveBeenCalled();
});

it("proceeds (no error) when there is no collision", async () => {
findLiveAppByName.mockResolvedValue(undefined);
const { cmd, errors } = makeCtx();
await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false });
expect(errors).toHaveLength(0);
});

it("fails open (warns, does not error) when the collision check throws", async () => {
findLiveAppByName.mockRejectedValue(new Error("rpc down"));
const { cmd, errors, warns } = makeCtx();

await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false });

expect(errors).toHaveLength(0);
expect(warns).toHaveLength(1);
expect(warns[0]).toContain(ARGS.name);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const reconcileReleaseDigest = vi.fn();

import { reconcileAndReport } from "../upgrade";

function makeCmd() {
const logs: string[] = [];
const warns: string[] = [];
return {
logs,
warns,
cmd: {
log: (m = "") => logs.push(m),
warn: (m: string) => warns.push(m),
debug: (_m: string) => {},
},
};
}

const APP = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09";
const DIGEST = `sha256:${"a".repeat(64)}`;

describe("reconcileAndReport", () => {
beforeEach(() => vi.clearAllMocks());

it("logs the confirmed digest when reconciliation matches", async () => {
const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockResolvedValue({ matched: true, lastDigest: DIGEST, elapsedMs: 10 }) } };
const { cmd, logs, warns } = makeCmd();
await reconcileAndReport(cmd as any, compute as any, APP, DIGEST);
expect(reconcileReleaseDigest).toHaveBeenCalledWith(APP, DIGEST);
expect(logs.join("\n")).toContain(DIGEST);
expect(warns).toHaveLength(0);
});

it("warns about pending propagation when reconciliation times out", async () => {
const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockResolvedValue({ matched: false, lastDigest: "sha256:old", elapsedMs: 45000 }) } };
const { cmd, warns } = makeCmd();
await reconcileAndReport(cmd as any, compute as any, APP, DIGEST);
expect(warns).toHaveLength(1);
expect(warns[0].toLowerCase()).toContain("propagation");
expect(warns[0]).toContain(APP);
});

it("skips silently when no expected digest is known", async () => {
const compute = { app: { reconcileReleaseDigest } };
const { cmd, warns } = makeCmd();
await reconcileAndReport(cmd as any, compute as any, APP, undefined);
expect(reconcileReleaseDigest).not.toHaveBeenCalled();
expect(warns).toHaveLength(0);
});

it("fails open (no throw, no warn) when reconciliation itself errors", async () => {
const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockRejectedValue(new Error("boom")) } };
const { cmd, warns } = makeCmd();
await expect(reconcileAndReport(cmd as any, compute as any, APP, DIGEST)).resolves.toBeUndefined();
expect(warns).toHaveLength(0);
});
});
Loading