From 8756984e2c559a19f747253e1b1b8085b36c03bc Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 22 Apr 2026 11:34:39 -0500 Subject: [PATCH 01/55] fix: correctly show credits remaining from new API --- packages/cli/src/commands/billing/status.ts | 27 ++++--------------- .../src/client/common/config/environment.ts | 13 ++++++++- .../sdk/src/client/common/utils/billingapi.ts | 20 +++++++++++--- .../sdk/src/client/modules/billing/index.ts | 2 +- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index fba72130..2089dd04 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -88,28 +88,11 @@ export default class BillingStatus extends Command { } } - // Display invoice summary with credits - if (result.creditsApplied !== undefined && result.creditsApplied > 0) { - this.log(`\n${chalk.bold(" Invoice Summary:")}`); - const subtotal = result.upcomingInvoiceSubtotal ?? result.upcomingInvoiceTotal ?? 0; - this.log(` Subtotal: $${subtotal.toFixed(2)}`); - this.log(` Credits Applied: ${chalk.green(`-$${result.creditsApplied.toFixed(2)}`)}`); - this.log(` ${"─".repeat(21)}`); - this.log(` Total Due: $${(result.upcomingInvoiceTotal ?? 0).toFixed(2)}`); - - if (result.remainingCredits !== undefined) { - this.log( - `\n ${chalk.bold("Remaining Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } else if (result.upcomingInvoiceTotal !== undefined) { - this.log(`\n Upcoming Invoice: $${result.upcomingInvoiceTotal.toFixed(2)}`); - if (result.remainingCredits !== undefined && result.remainingCredits > 0) { - this.log( - ` ${chalk.bold("Available Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } + // Display remaining credits + const credits = result.remainingCredits ?? 0; + this.log( + ` Credits: ${chalk.cyan(`$${credits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, + ); // Display cancellation information if (result.cancelAtPeriodEnd) { diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index db092f6f..7138392f 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -135,6 +135,12 @@ export function getEnvironmentConfig(environment: string, chainID?: bigint): Env ...env, chainID: BigInt(resolvedChainID), ...(apiUrlOverride ? { userApiServerURL: apiUrlOverride } : {}), + ...(process.env.ECLOUD_USER_API_URL && { + userApiServerURL: process.env.ECLOUD_USER_API_URL, + }), + ...(process.env.ECLOUD_RPC_URL && { + defaultRPCURL: process.env.ECLOUD_RPC_URL, + }), }; } @@ -153,7 +159,12 @@ export function getBillingEnvironmentConfig(build: "dev" | "prod"): { if (apiUrlOverride) { return { billingApiServerURL: apiUrlOverride }; } - return config; + return { + ...config, + ...(process.env.ECLOUD_BILLING_API_URL && { + billingApiServerURL: process.env.ECLOUD_BILLING_API_URL, + }), + }; } /** diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 3dbbfc37..d7f2a445 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -37,6 +37,8 @@ export interface BillingApiClientOptions { * When false (default), uses EIP-712 typed data signatures for each request. */ useSession?: boolean; + /** Log request/response details to stderr */ + verbose?: boolean; } /** @@ -191,10 +193,22 @@ export class BillingApiClient { productId: ProductID, body?: Record, ): Promise<{ json: () => Promise; text: () => Promise }> { - if (this.useSession) { - return this.makeSessionAuthenticatedRequest(url, method, body); + if (this.options.verbose) { + console.debug(`[BillingAPI] ${method} ${url}`); } - return this.makeSignatureAuthenticatedRequest(url, method, productId, body); + const resp = this.useSession + ? await this.makeSessionAuthenticatedRequest(url, method, body) + : await this.makeSignatureAuthenticatedRequest(url, method, productId, body); + + if (this.options.verbose) { + const data = await resp.json(); + console.debug(`[BillingAPI] Response:`, JSON.stringify(data, null, 2)); + return { + json: async () => data, + text: async () => JSON.stringify(data), + }; + } + return resp; } /** diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index d68f477d..b424c021 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -78,7 +78,7 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); // Create billing API client - const billingApi = new BillingApiClient(billingEnvConfig, walletClient); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); // Resolve on-chain config const environmentConfig = getEnvironmentConfig(environment); From 7ba8d1a63bda255c682fa8dca55007223deb8f09 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:43:29 -0500 Subject: [PATCH 02/55] feat(sdk): add PaymentMethod and CreditPurchaseResponse types Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client/common/types/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..1a0fda0d 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -419,6 +419,23 @@ export interface SubscriptionOpts { cancelUrl?: string; } +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} + // Billing environment configuration export interface BillingEnvironmentConfig { billingApiServerURL: string; From 59d48120a7f0975603b9e5a3aca2b85522e922aa Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:44:44 -0500 Subject: [PATCH 03/55] feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/src/client/common/utils/billingapi.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index d7f2a445..46285578 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -18,6 +18,8 @@ import { CreateSubscriptionResponse, GetSubscriptionOptions, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -178,6 +180,25 @@ export class BillingApiClient { await this.makeAuthenticatedRequest(endpoint, "DELETE", productId); } + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } + // ========================================================================== // Internal Methods // ========================================================================== From 89fc8b39bd062344eee1f8a7fc0647ccac5c815a Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:46:05 -0500 Subject: [PATCH 04/55] feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client/modules/billing/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index b424c021..e07009d3 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -23,6 +23,8 @@ import type { SubscribeResponse, CancelResponse, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, } from "../../common/types"; export interface TopUpOpts { @@ -53,6 +55,8 @@ export interface BillingModule { getTopUpInfo: () => Promise; /** Purchase credits with USDC on-chain */ topUp: (opts: TopUpOpts) => Promise; + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; } export interface BillingModuleConfig { @@ -281,6 +285,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule }, ); }, + + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, }; return module; From 72fb9e3d3177a3a935c9e5b016acd0a540568ffc Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:49:34 -0500 Subject: [PATCH 05/55] feat(cli): add credit card payment flow to billing top-up Rewrote the top-up command to support both USDC and credit card payment methods. Users can now choose between on-chain USDC payment or credit card checkout. Changes: - Added method flag to select payment method (usdc or card) - Extracted USDC flow into handleUsdc() method - Added handleCard() method for credit card checkout flow - Added pollForCredits() helper to share polling logic - Updated description and examples - Integrated with new SDK methods: getPaymentMethods and purchaseCredits Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/billing/top-up.ts | 291 +++++++++++++------- 1 file changed, 191 insertions(+), 100 deletions(-) diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 7637507b..43ca7c48 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -1,14 +1,15 @@ /** - * ecloud billing top-up — Purchase EigenCompute credits with USDC + * ecloud billing top-up — Purchase EigenCompute credits with USDC or credit card * * Executes USDCCredits.purchaseCreditsFor(amount, account) on-chain via the SDK - * billing module's topUp() method (EIP-7702 batched transaction). + * billing module's topUp() method (EIP-7702 batched transaction), or initiates + * credit card checkout via the purchaseCredits API. * * Flow: * 1. Check current credit balance - * 2. Read wallet's USDC balance via SDK - * 3. If USDC available → prompt for amount → SDK topUp() → poll for confirmation - * 4. If no USDC → show wallet address, tell user to fund it + * 2. Prompt for payment method (USDC or card) + * 3. USDC: Read wallet's USDC balance via SDK → prompt for amount → SDK topUp() → poll + * 4. Card: Prompt for amount → check existing payment methods → purchaseCredits API → poll */ import { Command, Flags } from "@oclif/core"; @@ -16,20 +17,32 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { type Address, formatUnits } from "viem"; import chalk from "chalk"; -import { input } from "@inquirer/prompts"; +import { input, select, confirm } from "@inquirer/prompts"; +import open from "open"; import { withTelemetry } from "../../telemetry"; const POLL_INTERVAL_MS = 5_000; const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes export default class BillingTopUp extends Command { - static description = "Purchase EigenCompute credits with USDC"; + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; static flags = { ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), amount: Flags.string({ required: false, - description: "Amount of USDC to spend (e.g., '50')", + description: "Amount to spend (USDC for on-chain, whole dollars for card)", }), account: Flags.string({ required: false, @@ -48,9 +61,7 @@ export default class BillingTopUp extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(BillingTopUp); - // Create billing client const billing = await createBillingClient(flags); - const walletAddress = billing.address; const targetAccount = (flags.account as Address) ?? walletAddress; @@ -61,9 +72,7 @@ export default class BillingTopUp extends Command { this.log(` ${chalk.bold("Target:")} ${targetAccount}`); } - // ── Step 1: Show current credit balance ── - // Track total credits (remaining + applied) so we detect top-ups even - // when new credits are immediately consumed by an outstanding bill. + // Show current credit balance let baselineTotal: number | undefined; try { const status = await billing.getStatus({ @@ -72,108 +81,190 @@ export default class BillingTopUp extends Command { const remaining = status.remainingCredits ?? 0; const applied = status.creditsApplied ?? 0; baselineTotal = remaining + applied; - if (status.remainingCredits !== undefined) { - this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${status.remainingCredits.toFixed(2)}`)}`); - } + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } catch { this.debug("Could not fetch current credit balance"); } - // ── Step 2: Read on-chain state via SDK ── - const onChainState = await billing.getTopUpInfo(); - const { usdcBalance, minimumPurchase } = onChainState; - - const balanceFormatted = formatUnits(usdcBalance, 6); - this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); - if (usdcBalance === BigInt(0)) { - this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); - this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); - this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); - return; + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); } + }); + } - // ── Step 3: Prompt for amount ── - const minimumFormatted = formatUnits(minimumPurchase, 6); - const amountStr = - flags.amount ?? - (await input({ - message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, - validate: (val) => { - const n = parseFloat(val); - if (isNaN(n) || n <= 0) return "Enter a positive number"; - const raw = BigInt(Math.round(n * 1e6)); - if (raw < minimumPurchase) - return `Minimum purchase is ${minimumFormatted} USDC`; - if (raw > usdcBalance) - return `Insufficient balance. You have ${balanceFormatted} USDC`; - return true; - }, - })); + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + const onChainState = await billing.getTopUpInfo(); + const { usdcBalance, minimumPurchase } = onChainState; - const amountFloat = parseFloat(amountStr); - const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); - if (amountRaw < minimumPurchase) { - this.error(`Minimum purchase is ${minimumFormatted} USDC`); - } - if (amountRaw > usdcBalance) { - this.error( - `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, - ); - } + if (usdcBalance === BigInt(0)) { + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } - this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; - // ── Step 4: Execute on-chain purchase via SDK ── - const { txHash } = await billing.topUp({ - amount: amountRaw, - account: targetAccount, + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let useExistingCard = false; + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const card = paymentMethods[0]; + const lastFour = card.stripePaymentMethodId.slice(-4); + useExistingCard = await confirm({ + message: `Use card on file (ending in ${lastFour})?`, + default: true, }); - this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); - - // ── Step 5: Poll billing API for credit confirmation ── - this.log(chalk.gray("\n Waiting for credits to appear...")); - const startTime = Date.now(); - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - try { - const status = await billing.getStatus({ - productId: flags.product as "compute", - }); - const remaining = status.remainingCredits ?? 0; - const applied = status.creditsApplied ?? 0; - const currentTotal = remaining + applied; - this.debug(`Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`); - if ( - baselineTotal === undefined || currentTotal > baselineTotal - ) { - const creditsAdded = baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; - const isMatched = creditsAdded !== undefined && Math.abs(creditsAdded - amountFloat * 2) < 0.01; - const appliedFromTopUp = creditsAdded !== undefined ? creditsAdded - remaining : 0; - - this.log(`\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountFloat).toFixed(2)}`)}`); - if (isMatched) { - this.log(` ${chalk.green("✓")} Includes $${amountFloat.toFixed(2)} match bonus!`); - } - if (remaining > 0) { - this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); - } - if (appliedFromTopUp > 0) { - this.log(` ${chalk.gray(`$${appliedFromTopUp.toFixed(2)} applied to current bill`)}`); - } - this.log(); - return; + if (useExistingCard) { + paymentMethodId = card.id; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } + + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } - } catch { - this.debug("Error polling for credit balance"); + this.log(); + return; } + } catch { + this.debug("Error polling for credit balance"); } + } - this.log( - `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, - ); - this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); - }); + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); } } From 4407666ed1d0929526d4164d2008ad18aaf8a3a5 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Thu, 23 Apr 2026 16:52:24 -0500 Subject: [PATCH 06/55] test(cli): add credit card flow tests for billing top-up Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/billing/__tests__/top-up.test.ts | 158 +++++++++++++++--- 1 file changed, 135 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index 986e02f2..9c38fdaf 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -10,11 +10,17 @@ vi.mock("../../../telemetry", () => ({ vi.mock("@inquirer/prompts", () => ({ input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), })); import BillingTopUp from "../top-up"; import { createBillingClient } from "../../../client"; -import { input } from "@inquirer/prompts"; +import { input, select, confirm } from "@inquirer/prompts"; const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; @@ -26,6 +32,8 @@ describe("ecloud billing top-up", () => { getStatus: ReturnType; getTopUpInfo: ReturnType; topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; }; beforeEach(() => { @@ -37,6 +45,8 @@ describe("ecloud billing top-up", () => { getStatus: vi.fn(), getTopUpInfo: vi.fn(), topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), }; (createBillingClient as ReturnType).mockResolvedValue(mockBilling); @@ -86,6 +96,8 @@ describe("ecloud billing top-up", () => { return cmd; } + // ── USDC Tests ── + it("happy path: sufficient balance, purchase succeeds", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); @@ -93,29 +105,22 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance timers to resolve the polling setTimeout for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); } await promise; const fullOutput = logOutput.join("\n"); - // Shows wallet address expect(fullOutput).toContain(WALLET_ADDRESS); - // Shows credits expect(fullOutput).toContain("$10.00"); - // Shows USDC balance expect(fullOutput).toContain("100 USDC"); - // Shows purchase step expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Shows final balance after polling expect(fullOutput).toContain("Credits received"); expect(fullOutput).toContain("$60.00"); - // Verify topUp was called with correct args expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: WALLET_ADDRESS, @@ -126,7 +131,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ usdcBalance: BigInt(0) }); mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); await cmd.run(); const fullOutput = logOutput.join("\n"); @@ -134,7 +139,6 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Send USDC on Sepolia to"); expect(fullOutput).toContain(WALLET_ADDRESS); - // Should not have called topUp expect(mockBilling.topUp).not.toHaveBeenCalled(); }); @@ -142,7 +146,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ minimumPurchase: BigInt(10_000_000) }); // 10 USDC minimum mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "5" }); + const cmd = createCommand({ amount: "5", method: "usdc" }); await expect(cmd.run()).rejects.toThrow("Minimum purchase is 10 USDC"); }); @@ -154,7 +158,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50", account: targetAccount }); + const cmd = createCommand({ amount: "50", method: "usdc", account: targetAccount }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -162,10 +166,8 @@ describe("ecloud billing top-up", () => { await promise; const fullOutput = logOutput.join("\n"); - // Shows target account expect(fullOutput).toContain(targetAccount); - // Verify topUp was called with the target account expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: targetAccount, @@ -175,15 +177,13 @@ describe("ecloud billing top-up", () => { it("billing API poll timeout: shows timeout message", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); - // getStatus always returns the same credits (no increase) mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0, }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past the 3-minute poll timeout await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); @@ -199,7 +199,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "inactive" }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 100.0 }); - const cmd = createCommand({ amount: "100" }); + const cmd = createCommand({ amount: "100", method: "usdc" }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -214,17 +214,129 @@ describe("ecloud billing top-up", () => { mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); mockBilling.getStatus.mockRejectedValue(new Error("API unavailable")); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past poll timeout since getStatus always errors await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); - // Should still proceed with on-chain purchase expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Will timeout on polling since status always errors expect(fullOutput).toContain("Credits haven't appeared yet"); }); + + // ── Credit Card Tests ── + + it("credit card: charges existing card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); + + it("credit card: opens checkout when user declines existing card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(confirm).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); + + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); }); From 49f6f2bcdc613f7030c558e23c4b41dc0594f0fb Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 29 Apr 2026 10:21:10 -0500 Subject: [PATCH 07/55] execution plan --- .../plans/2026-04-23-top-up-credit-card.md | 770 ++++++++++++++++++ .../2026-04-23-top-up-credit-card-design.md | 211 +++++ 2 files changed, 981 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-top-up-credit-card.md create mode 100644 docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md diff --git a/docs/superpowers/plans/2026-04-23-top-up-credit-card.md b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md new file mode 100644 index 00000000..0f391169 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md @@ -0,0 +1,770 @@ +# Top-Up Credit Card Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add credit card payment support to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +**Architecture:** Two new SDK methods (`getPaymentMethods`, `purchaseCredits`) on the existing `BillingApiClient` call the `/v1/payment-methods` and `/v1/credits/purchase` endpoints using the same EIP-712 auth. The CLI `top-up` command gets a `--method` flag and branches into the existing USDC path or a new credit card path with card-on-file detection. + +**Tech Stack:** TypeScript, oclif (CLI framework), viem (wallet), vitest (tests), `open` package (browser), `@inquirer/prompts` (interactive prompts) + +**Spec:** `docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `packages/sdk/src/client/common/types/index.ts` | Modify | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` types | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Modify | Add `getPaymentMethods()` and `purchaseCredits()` methods to `BillingApiClient` | +| `packages/sdk/src/client/modules/billing/index.ts` | Modify | Expose new methods on `BillingModule` interface and wire them in `createBillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Modify | Add `--method` flag, payment method selection prompt, credit card purchase flow | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Modify | Add credit card flow test cases | + +--- + +## Task 1: Add new types to SDK + +**Files:** +- Modify: `packages/sdk/src/client/common/types/index.ts:420-425` (after `SubscriptionOpts`, before `BillingEnvironmentConfig`) + +- [ ] **Step 1: Add the new type definitions** + +Insert after the `SubscriptionOpts` interface (line 420) and before the `BillingEnvironmentConfig` interface (line 422): + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +- [ ] **Step 2: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk/src/client/common/types/index.ts +git commit -m "feat(sdk): add PaymentMethod and CreditPurchaseResponse types" +``` + +--- + +## Task 2: Add `getPaymentMethods()` and `purchaseCredits()` to `BillingApiClient` + +**Files:** +- Modify: `packages/sdk/src/client/common/utils/billingapi.ts:176-179` (after `cancelSubscription`, before the Internal Methods section) + +- [ ] **Step 1: Add the import for new types** + +In `billingapi.ts`, add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the existing import from `"../types"`: + +```typescript +import { + ProductID, + CreateSubscriptionOptions, + CreateSubscriptionResponse, + GetSubscriptionOptions, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../types"; +``` + +- [ ] **Step 2: Add `getPaymentMethods()` method** + +Insert after `cancelSubscription` (line 178) and before the `// Internal Methods` comment (line 181): + +```typescript + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } +``` + +- [ ] **Step 3: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/common/utils/billingapi.ts +git commit -m "feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient" +``` + +--- + +## Task 3: Expose new methods on `BillingModule` + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts:47-56` (BillingModule interface) and `~90` (module object in `createBillingModule`) + +- [ ] **Step 1: Add imports for new types** + +Add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the import from `"../../common/types"`: + +```typescript +import type { + ProductID, + SubscriptionOpts, + SubscribeResponse, + CancelResponse, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../../common/types"; +``` + +- [ ] **Step 2: Extend the `BillingModule` interface** + +Add these two methods to the `BillingModule` interface (after the `topUp` method, before the closing brace): + +```typescript + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +``` + +- [ ] **Step 3: Wire the methods in `createBillingModule`** + +Inside the `const module: BillingModule = { ... }` object, after the `cancel` method definition (around line 283), add: + +```typescript + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, +``` + +- [ ] **Step 4: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts +git commit -m "feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule" +``` + +--- + +## Task 4: Add credit card flow to `top-up.ts` CLI command + +**Files:** +- Modify: `packages/cli/src/commands/billing/top-up.ts` + +- [ ] **Step 1: Update imports** + +Replace the existing imports at the top of the file: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import { type Address, formatUnits } from "viem"; +import chalk from "chalk"; +import { input, select, confirm } from "@inquirer/prompts"; +import open from "open"; +import { withTelemetry } from "../../telemetry"; +``` + +Note: `select` and `confirm` are added from `@inquirer/prompts`; `open` is added for opening checkout URLs in browser. + +- [ ] **Step 2: Update command description and add `--method` flag** + +Update the static properties on the class: + +```typescript +export default class BillingTopUp extends Command { + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; + + static flags = { + ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), + amount: Flags.string({ + required: false, + description: "Amount to spend (USDC for on-chain, whole dollars for card)", + }), + account: Flags.string({ + required: false, + description: "Target account address for purchaseCreditsFor (defaults to your wallet)", + }), + product: Flags.string({ + required: false, + description: "Product ID", + default: "compute", + options: ["compute"], + env: "ECLOUD_PRODUCT_ID", + }), + }; +``` + +- [ ] **Step 3: Update the `run()` method — payment method selection and branching** + +Replace the entire `run()` method body. The structure is: + +1. Create billing client, show wallet info and current credits (unchanged). +2. Select payment method (prompt or flag). +3. Branch to USDC path or credit card path. + +```typescript + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingTopUp); + + const billing = await createBillingClient(flags); + const walletAddress = billing.address; + const targetAccount = (flags.account as Address) ?? walletAddress; + + this.log(`\n${chalk.bold("Purchase EigenCompute credits")}`); + this.log(`${chalk.gray("─".repeat(45))}`); + this.log(`\n ${chalk.bold("Wallet:")} ${walletAddress}`); + if (targetAccount !== walletAddress) { + this.log(` ${chalk.bold("Target:")} ${targetAccount}`); + } + + // Show current credit balance + let baselineTotal: number | undefined; + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + baselineTotal = remaining + applied; + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } catch { + this.debug("Could not fetch current credit balance"); + } + + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); + + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); + } + }); + } +``` + +- [ ] **Step 4: Extract USDC path into `handleUsdc` method** + +Add this private method. This is the existing USDC flow extracted with no logic changes: + +```typescript + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + const onChainState = await billing.getTopUpInfo(); + const { usdcBalance, minimumPurchase } = onChainState; + + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + + if (usdcBalance === BigInt(0)) { + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } +``` + +- [ ] **Step 5: Add `handleCard` method** + +```typescript + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; + + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let useExistingCard = false; + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const card = paymentMethods[0]; + const lastFour = card.stripePaymentMethodId.slice(-4); + useExistingCard = await confirm({ + message: `Use card on file (ending in ${lastFour})?`, + default: true, + }); + if (useExistingCard) { + paymentMethodId = card.id; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } +``` + +- [ ] **Step 6: Extract shared polling into `pollForCredits` method** + +```typescript + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } + this.log(); + return; + } + } catch { + this.debug("Error polling for credit balance"); + } + } + + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); + } +``` + +- [ ] **Step 7: Verify types compile** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/commands/billing/top-up.ts +git commit -m "feat(cli): add credit card payment flow to billing top-up" +``` + +--- + +## Task 5: Update tests for credit card flow + +**Files:** +- Modify: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +- [ ] **Step 1: Update mocks to include new imports** + +Replace the mock setup at the top of the file. The `@inquirer/prompts` mock needs `select` and `confirm` added; `open` needs to be mocked: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../../client", () => ({ + createBillingClient: vi.fn(), +})); + +vi.mock("../../../telemetry", () => ({ + withTelemetry: vi.fn((_cmd: unknown, fn: () => Promise) => fn()), +})); + +vi.mock("@inquirer/prompts", () => ({ + input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), +})); + +import BillingTopUp from "../top-up"; +import { createBillingClient } from "../../../client"; +import { input, select, confirm } from "@inquirer/prompts"; +``` + +- [ ] **Step 2: Update `mockBilling` in `beforeEach` to include new methods** + +```typescript + let mockBilling: { + address: string; + getStatus: ReturnType; + getTopUpInfo: ReturnType; + topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + logOutput = []; + mockBilling = { + address: WALLET_ADDRESS, + getStatus: vi.fn(), + getTopUpInfo: vi.fn(), + topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + }; + (createBillingClient as ReturnType).mockResolvedValue(mockBilling); + + (input as ReturnType).mockResolvedValue("50"); + }); +``` + +- [ ] **Step 3: Update existing USDC tests** + +The existing tests need to set `flags.method: "usdc"` since the flow now branches on method. Update the `createCommand` helper: + +```typescript + function createCommand(flags: Record = {}) { + const cmd = new BillingTopUp([], {} as any); + cmd.parse = vi.fn().mockResolvedValue({ + flags: { + product: "compute", + "private-key": "0xdeadbeef", + environment: "sepolia-dev", + ...flags, + }, + }); + cmd.log = vi.fn((...args: string[]) => logOutput.push(args.join(" "))); + cmd.debug = vi.fn(); + cmd.error = vi.fn((msg: string) => { + throw new Error(msg); + }) as any; + return cmd; + } +``` + +For each existing test that passes `amount` as a flag, also add `method: "usdc"`. For example, the "happy path" test becomes: + +```typescript + const cmd = createCommand({ amount: "50", method: "usdc" }); +``` + +Apply this change to all existing tests: +- "happy path: sufficient balance, purchase succeeds" → `{ amount: "50", method: "usdc" }` +- "zero USDC balance: exits with fund wallet message" → `{ amount: "50", method: "usdc" }` +- "below minimum purchase: shows error" → `{ amount: "5", method: "usdc" }` +- "--account flag: passes different address to topUp" → `{ amount: "50", method: "usdc", account: targetAccount }` +- "billing API poll timeout: shows timeout message" → `{ amount: "50", method: "usdc" }` +- "uses --amount flag when provided (skips prompt)" → `{ amount: "100", method: "usdc" }` +- "does not fail if status check errors" → `{ amount: "50", method: "usdc" }` + +- [ ] **Step 4: Run existing USDC tests to make sure they still pass** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All existing tests pass. + +- [ ] **Step 5: Add credit card test — card on file, user accepts** + +```typescript + it("credit card: charges existing card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); +``` + +- [ ] **Step 6: Add credit card test — card on file, user declines (wants new card)** + +```typescript + it("credit card: opens checkout when user declines existing card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 7: Add credit card test — no card on file** + +```typescript + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(confirm).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 8: Add credit card test — amount below $5 minimum** + +```typescript + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); +``` + +- [ ] **Step 9: Add credit card test — `--method card --amount 50` skips prompts** + +```typescript + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 10: Run all tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All tests pass (existing USDC tests + new credit card tests). + +- [ ] **Step 11: Commit** + +```bash +git add packages/cli/src/commands/billing/__tests__/top-up.test.ts +git commit -m "test(cli): add credit card flow tests for billing top-up" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Run full SDK type check** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 2: Run full CLI type check** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 3: Run all billing tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/` +Expected: All tests pass. + +- [ ] **Step 4: Commit any remaining fixes if needed** diff --git a/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md new file mode 100644 index 00000000..0df449d7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md @@ -0,0 +1,211 @@ +# Top-Up Credit Card Support + +Add credit card purchasing to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +## Motivation + +We're moving to a credit-based burndown system. Users need to purchase credits, and not everyone wants to use USDC on-chain. Adding credit card support via Stripe lets users top up with a familiar payment method. + +## API Routes + +Both routes live on the billing API server (`ECLOUD_BILLING_API_URL`), use the same EIP-712 signature auth as existing routes (`Authorization: Bearer `, `X-Account`, `X-Expiry`). + +### GET /v1/payment-methods + +Returns saved payment methods for the authenticated wallet. + +Request: no body, auth required. + +Response: +```json +{ + "paymentMethods": [ + { + "id": "029641fc-3e5c-11f1-986c-5601121cbf6d", + "stripePaymentMethodId": "pm_1ABC123...", + "createdAt": "2026-04-20T15:00:00Z" + } + ] +} +``` + +### POST /v1/credits/purchase + +Two modes depending on whether `paymentMethodId` is provided. + +**Direct charge (card on file):** + +Request: +```json +{ + "amountCents": 5000, + "paymentMethodId": "029641fc-3e5c-11f1-986c-5601121cbf6d" +} +``` + +Response: +```json +{ + "purchaseId": "a1b2c3d4-5e6f-11f1-986c-5601121cbf6d", + "amountCents": "5000" +} +``` + +**Checkout session (no card on file):** + +Request: +```json +{ + "amountCents": 5000 +} +``` + +Response: +```json +{ + "checkoutSessionId": "cs_test_abc123...", + "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_abc123...", + "amountCents": "5000" +} +``` + +Minimum `amountCents`: 500 ($5.00). + +## Design + +### SDK: New methods on `BillingApiClient` + +File: `packages/sdk/src/client/common/utils/billingapi.ts` + +Add two methods to the existing `BillingApiClient` class: + +```typescript +async getPaymentMethods(): Promise +``` +- `GET ${billingApiServerURL}/v1/payment-methods` +- Uses `makeAuthenticatedRequest` with a dummy productId (e.g. `"compute"`) for signature generation since the auth scheme requires a product field. + +```typescript +async purchaseCredits(amountCents: number, paymentMethodId?: string): Promise +``` +- `POST ${billingApiServerURL}/v1/credits/purchase` +- Body: `{ amountCents }` or `{ amountCents, paymentMethodId }` depending on whether a payment method is provided. +- Uses `makeAuthenticatedRequest`. + +### SDK: New types + +File: `packages/sdk/src/client/common/types/index.ts` + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +`CreditPurchaseResponse` is a union-style interface: a direct charge returns `purchaseId` without checkout fields; a checkout session returns `checkoutSessionId` + `checkoutUrl` without `purchaseId`. + +### SDK: Export new methods from billing module + +File: `packages/sdk/src/client/modules/billing/index.ts` + +Expose the two new `BillingApiClient` methods through the `BillingModule` interface: + +```typescript +export interface BillingModule { + // ... existing methods ... + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +} +``` + +Wire them to `billingApi.getPaymentMethods()` and `billingApi.purchaseCredits()` in `createBillingModule`. + +### CLI: Modified `top-up.ts` command + +File: `packages/cli/src/commands/billing/top-up.ts` + +#### New flag + +``` +--method usdc | card (optional, prompts if omitted) +``` + +#### Updated flow + +1. Show wallet address and current credit balance (unchanged). +2. **Payment method selection:** + - If `--method usdc` -> go to USDC path. + - If `--method card` -> go to credit card path. + - If no flag -> prompt user to choose between "USDC (on-chain)" and "Credit card". +3. **USDC path:** Unchanged from current implementation (steps 2-5 in existing code). +4. **Credit card path:** + a. Prompt for dollar amount (whole dollars, minimum $5). Skipped if `--amount` flag is provided. + b. Convert to cents: `amountCents = dollars * 100`. + c. Call `billing.getPaymentMethods()`. + d. If payment methods exist: + - Show: "Use card on file (pm_...1ABC)?" with yes/no prompt. + - If yes: call `billing.purchaseCredits(amountCents, paymentMethod.id)`. This returns `{ purchaseId, amountCents }`. Proceed to poll for credits. + - If no: call `billing.purchaseCredits(amountCents)` without payment method ID. This returns a checkout URL. Open in browser with `open`. Proceed to poll for credits. + e. If no payment methods: call `billing.purchaseCredits(amountCents)` (no payment method ID). Open checkout URL in browser. Proceed to poll for credits. +5. **Credit polling:** Same polling loop as today — poll `billing.getStatus()` until `remainingCredits` increases or timeout (3 minutes). + +#### Amount validation (credit card path) + +- Must be a whole dollar amount (integer). +- Minimum: $5 (500 cents). +- No maximum (Stripe handles limits). + +#### Non-interactive support + +For CI/scripting, all prompts can be skipped via flags: +- `--method card --amount 50` skips the method and amount prompts. +- Without a card on file, the checkout URL is printed to stdout (the `open` call will be attempted but the URL is always logged). +- With a card on file and no flag to choose it, the command will still prompt. Full non-interactive card selection is out of scope for this change. + +### CLI: Update command description and examples + +Update `static description` and `static examples` to reflect the new credit card option. + +### Tests + +File: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +Add test cases: +- **Credit card, card on file, user accepts:** mock `getPaymentMethods` returning one card, mock `purchaseCredits` returning `{ purchaseId, amountCents }`, verify no browser open, verify credit polling. +- **Credit card, card on file, user declines (wants new card):** mock `purchaseCredits` returning `{ checkoutUrl, ... }`, verify `open` is called with checkout URL. +- **Credit card, no card on file:** mock `getPaymentMethods` returning empty array, mock `purchaseCredits` returning checkout URL, verify `open` is called. +- **`--method card --amount 50` skips prompts:** verify `select` and `input` are not called. +- **Amount below $5 minimum:** verify validation error. +- **Existing USDC tests remain unchanged.** + +Mock `billing.getPaymentMethods` and `billing.purchaseCredits` on the same `mockBilling` object used by existing tests. Mock `open` as already done in `subscribe.test.ts`. + +## Files changed + +| File | Change | +|------|--------| +| `packages/sdk/src/client/common/types/index.ts` | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Add `getPaymentMethods()`, `purchaseCredits()` | +| `packages/sdk/src/client/modules/billing/index.ts` | Expose new methods on `BillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Add `--method` flag, credit card flow, method selection prompt | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Add credit card test cases | + +## Out of scope + +- Listing/managing saved payment methods (separate command later). +- Deleting payment methods. +- Full non-interactive card selection (auto-picking a saved card without prompting). +- Changing the subscribe command flow. From 562f22a4234f025724dd6375081ac529f7519fe2 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Mon, 4 May 2026 21:20:58 -0500 Subject: [PATCH 08/55] feat: add list-cards subcommand --- .../commands/billing/__tests__/top-up.test.ts | 24 +++++++++---- .../cli/src/commands/billing/list-cards.ts | 36 +++++++++++++++++++ packages/cli/src/commands/billing/top-up.ts | 26 +++++++++----- packages/sdk/src/client/common/types/index.ts | 2 ++ .../sdk/src/client/common/utils/billingapi.ts | 3 ++ 5 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/billing/list-cards.ts diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index 9c38fdaf..cdb4fcc8 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -11,7 +11,6 @@ vi.mock("../../../telemetry", () => ({ vi.mock("@inquirer/prompts", () => ({ input: vi.fn(), select: vi.fn(), - confirm: vi.fn(), })); vi.mock("open", () => ({ @@ -20,7 +19,7 @@ vi.mock("open", () => ({ import BillingTopUp from "../top-up"; import { createBillingClient } from "../../../client"; -import { input, select, confirm } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; @@ -227,7 +226,7 @@ describe("ecloud billing top-up", () => { // ── Credit Card Tests ── - it("credit card: charges existing card on file", async () => { + it("credit card: charges selected card on file", async () => { mockBilling.getStatus .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); @@ -236,15 +235,24 @@ describe("ecloud billing top-up", () => { { id: "029641fc-3e5c-11f1-986c-5601121cbf6d", stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", createdAt: "2026-04-20T15:00:00Z", }, + { + id: "139752fd-4e6d-22f2-a97d-6712232dcg7e", + stripePaymentMethodId: "pm_2DEF5678", + brand: "mastercard", + last4: "5678", + createdAt: "2026-04-21T10:00:00Z", + }, ], }); mockBilling.purchaseCredits.mockResolvedValue({ purchaseId: "a1b2c3d4", amountCents: "2500", }); - (confirm as unknown as ReturnType).mockResolvedValue(true); + (select as unknown as ReturnType).mockResolvedValue("029641fc-3e5c-11f1-986c-5601121cbf6d"); const cmd = createCommand({ amount: "25", method: "card" }); const promise = cmd.run(); @@ -259,7 +267,7 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Credits received"); }); - it("credit card: opens checkout when user declines existing card", async () => { + it("credit card: opens checkout when user selects add new card", async () => { const openMock = (await import("open")).default as ReturnType; mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); mockBilling.getPaymentMethods.mockResolvedValue({ @@ -267,6 +275,8 @@ describe("ecloud billing top-up", () => { { id: "029641fc-3e5c-11f1-986c-5601121cbf6d", stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", createdAt: "2026-04-20T15:00:00Z", }, ], @@ -276,7 +286,7 @@ describe("ecloud billing top-up", () => { checkoutUrl: "https://checkout.stripe.com/test", amountCents: "2500", }); - (confirm as unknown as ReturnType).mockResolvedValue(false); + (select as unknown as ReturnType).mockResolvedValue("new"); const cmd = createCommand({ amount: "25", method: "card" }); const promise = cmd.run(); @@ -305,7 +315,7 @@ describe("ecloud billing top-up", () => { await promise; const fullOutput = logOutput.join("\n"); - expect(confirm).not.toHaveBeenCalled(); + expect(select).not.toHaveBeenCalled(); expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); expect(fullOutput).toContain("https://checkout.stripe.com/test"); diff --git a/packages/cli/src/commands/billing/list-cards.ts b/packages/cli/src/commands/billing/list-cards.ts new file mode 100644 index 00000000..1e0fafb6 --- /dev/null +++ b/packages/cli/src/commands/billing/list-cards.ts @@ -0,0 +1,36 @@ +import { Command } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; + +export default class BillingListCards extends Command { + static description = "List credit cards on file"; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingListCards); + const billing = await createBillingClient(flags); + + const { paymentMethods } = await billing.getPaymentMethods(); + + if (paymentMethods.length === 0) { + this.log(`\n ${chalk.gray("No cards on file.")}`); + this.log(` Run ${chalk.cyan("ecloud billing top-up --method card")} to add one.\n`); + return; + } + + this.log(`\n${chalk.bold("Cards on file:")}`); + for (const card of paymentMethods) { + const brand = card.brand.charAt(0).toUpperCase() + card.brand.slice(1); + const added = new Date(card.createdAt).toLocaleDateString(); + this.log(` • ${brand} ending in ${chalk.bold(card.last4)} ${chalk.gray(`added ${added}`)}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 43ca7c48..2708dbae 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -17,7 +17,7 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { type Address, formatUnits } from "viem"; import chalk from "chalk"; -import { input, select, confirm } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; import open from "open"; import { withTelemetry } from "../../telemetry"; @@ -195,18 +195,22 @@ export default class BillingTopUp extends Command { // Check for existing payment methods const { paymentMethods } = await billing.getPaymentMethods(); - let useExistingCard = false; let paymentMethodId: string | undefined; if (paymentMethods.length > 0) { - const card = paymentMethods[0]; - const lastFour = card.stripePaymentMethodId.slice(-4); - useExistingCard = await confirm({ - message: `Use card on file (ending in ${lastFour})?`, - default: true, + const choices = paymentMethods.map((card) => ({ + value: card.id, + name: `${card.brand.charAt(0).toUpperCase() + card.brand.slice(1)} ending in ${card.last4}`, + })); + choices.push({ value: "new", name: "Add a new card" }); + + const selection = await select({ + message: "Which card would you like to use?", + choices, }); - if (useExistingCard) { - paymentMethodId = card.id; + + if (selection !== "new") { + paymentMethodId = selection; } } @@ -218,6 +222,10 @@ export default class BillingTopUp extends Command { this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); this.log(chalk.gray(" Opening checkout in browser...")); await open(result.checkoutUrl); + } else if (result.checkoutSessionId) { + this.error( + "Checkout session created but no URL was returned. Please contact support.", + ); } else { this.log(` ${chalk.green("✓")} Payment submitted`); } diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index 1a0fda0d..f6d10340 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -422,6 +422,8 @@ export interface SubscriptionOpts { export interface PaymentMethod { id: string; stripePaymentMethodId: string; + brand: string; + last4: string; createdAt: string; } diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 46285578..3edd3b3a 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -216,6 +216,9 @@ export class BillingApiClient { ): Promise<{ json: () => Promise; text: () => Promise }> { if (this.options.verbose) { console.debug(`[BillingAPI] ${method} ${url}`); + if (body) { + console.debug(`[BillingAPI] Payload:`, JSON.stringify(body, null, 2)); + } } const resp = this.useSession ? await this.makeSessionAuthenticatedRequest(url, method, body) From 8667796cdff34bf6b9085dba60c8176d5670db50 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:49:00 -0500 Subject: [PATCH 09/55] feat: add Base Sepolia chain config for USDC credit purchases --- packages/sdk/src/client/common/config/environment.ts | 5 +++++ packages/sdk/src/client/common/constants.ts | 4 ++-- packages/sdk/src/client/common/types/index.ts | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index 7138392f..0bb4d76d 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -8,6 +8,7 @@ import { BillingEnvironmentConfig, EnvironmentConfig } from "../types"; // Chain IDs export const SEPOLIA_CHAIN_ID = 11155111; export const MAINNET_CHAIN_ID = 1; +export const BASE_SEPOLIA_CHAIN_ID = 84532; // Common addresses across all chains export const CommonAddresses: Record = { @@ -46,6 +47,8 @@ const ENVIRONMENTS: Record> = { userApiServerURL: "https://userapi-compute-sepolia-dev.eigencloud.xyz", defaultRPCURL: "https://ethereum-sepolia-rpc.publicnode.com", usdcCreditsAddress: "0xbdA3897c3A428763B59015C64AB766c288C97376", + baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", + baseRPCURL: "https://base-sepolia-rpc.publicnode.com", }, sepolia: { name: "sepolia", @@ -58,6 +61,8 @@ const ENVIRONMENTS: Record> = { defaultRPCURL: "https://ethereum-sepolia-rpc.publicnode.com", billingRPCURL: "https://ethereum-rpc.publicnode.com", usdcCreditsAddress: "0xed9c88640ca9149Bd9f7ee6620074af10F2E145d", + baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", + baseRPCURL: "https://base-sepolia-rpc.publicnode.com", }, "mainnet-alpha": { name: "mainnet-alpha", diff --git a/packages/sdk/src/client/common/constants.ts b/packages/sdk/src/client/common/constants.ts index b4528512..291aed6b 100644 --- a/packages/sdk/src/client/common/constants.ts +++ b/packages/sdk/src/client/common/constants.ts @@ -2,9 +2,9 @@ * Constants used throughout the SDK */ -import { sepolia, mainnet } from "viem/chains"; +import { sepolia, mainnet, baseSepolia } from "viem/chains"; -export const SUPPORTED_CHAINS = [mainnet, sepolia] as const; +export const SUPPORTED_CHAINS = [mainnet, sepolia, baseSepolia] as const; export const DOCKER_PLATFORM = "linux/amd64"; export const REGISTRY_PROPAGATION_WAIT_SECONDS = 3; diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index f6d10340..c16a7785 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -244,6 +244,8 @@ export interface EnvironmentConfig { defaultRPCURL: string; billingRPCURL?: string; usdcCreditsAddress?: Address; + baseUsdcCreditsAddress?: Address; + baseRPCURL?: string; } export interface Release { From 72ee53dfe1580b033cf95a43ba249340a2e23560 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:50:25 -0500 Subject: [PATCH 10/55] feat: add BillingChain type and hasBaseSupport to billing module interface --- packages/sdk/src/client/modules/billing/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index e07009d3..b9a272be 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -27,11 +27,15 @@ import type { CreditPurchaseResponse, } from "../../common/types"; +export type BillingChain = "ethereum" | "base"; + export interface TopUpOpts { /** Amount in raw USDC units (6 decimals, e.g. 50_000_000n = 50 USDC) */ amount: bigint; /** Target account for purchaseCreditsFor (defaults to wallet address) */ account?: Address; + /** Which blockchain to transact on (defaults to "ethereum") */ + chain?: BillingChain; } export interface TopUpResult { @@ -52,11 +56,13 @@ export interface BillingModule { getStatus: (opts?: SubscriptionOpts) => Promise; cancel: (opts?: SubscriptionOpts) => Promise; /** Read on-chain state needed for top-up */ - getTopUpInfo: () => Promise; + getTopUpInfo: (opts?: { chain?: BillingChain }) => Promise; /** Purchase credits with USDC on-chain */ topUp: (opts: TopUpOpts) => Promise; getPaymentMethods: () => Promise; purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; + /** Check if Base chain is configured for this environment */ + hasBaseSupport: () => boolean; } export interface BillingModuleConfig { From af72ae991e8c0883cfe870d7191e47e5904abeab Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:53:53 -0500 Subject: [PATCH 11/55] feat: implement chain-aware getTopUpInfo and topUp for Base support --- packages/cli/src/client.ts | 1 + .../sdk/src/client/modules/billing/index.ts | 87 +++++++++++++++---- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 2478aa0b..62771f3e 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -67,6 +67,7 @@ export async function createBillingClient(flags: CommonFlags) { publicClient, environment, skipTelemetry: true, + privateKey: privateKey as Hex, }); } diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index b9a272be..3802250a 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -9,7 +9,8 @@ import type { WalletClient, PublicClient } from "viem"; import { type Address, type Hex, encodeFunctionData } from "viem"; import { BillingApiClient } from "../../common/utils/billingapi"; -import { getBillingEnvironmentConfig, getBuildType, getEnvironmentConfig } from "../../common/config/environment"; +import { getBillingEnvironmentConfig, getBuildType, getEnvironmentConfig, BASE_SEPOLIA_CHAIN_ID } from "../../common/config/environment"; +import { createClients } from "../../common/utils/helpers"; import { getLogger, isSubscriptionActive } from "../../common/utils"; import { withSDKTelemetry } from "../../common/telemetry/wrapper"; import { executeBatch, type Execution } from "../../common/contract/eip7702"; @@ -71,10 +72,11 @@ export interface BillingModuleConfig { skipTelemetry?: boolean; // Skip telemetry when called from CLI publicClient: PublicClient; environment: string; + privateKey?: Hex; } export function createBillingModule(config: BillingModuleConfig): BillingModule { - const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment } = config; + const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment, privateKey } = config; // Get address from wallet client's account if (!walletClient.account) { @@ -92,38 +94,80 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule // Resolve on-chain config const environmentConfig = getEnvironmentConfig(environment); - const usdcCreditsAddress = environmentConfig.usdcCreditsAddress; - if (!usdcCreditsAddress) { + if (!environmentConfig.usdcCreditsAddress) { throw new Error(`USDCCredits contract address not configured for environment "${environment}"`); } + const usdcCreditsAddress: Address = environmentConfig.usdcCreditsAddress; + + const baseUsdcCreditsAddress = environmentConfig.baseUsdcCreditsAddress; + const baseRPCURL = environmentConfig.baseRPCURL; + + function resolveChainConfig(chain?: BillingChain): { + pub: PublicClient; + wallet: WalletClient; + creditsAddress: Address; + envConfig: typeof environmentConfig; + } { + if (chain === "base") { + if (!baseUsdcCreditsAddress || !baseRPCURL) { + throw new Error(`Base chain not configured for environment "${environment}"`); + } + if (!privateKey) { + throw new Error("Private key required for Base chain transactions"); + } + const baseClients = createClients({ + privateKey, + rpcUrl: baseRPCURL, + chainId: BigInt(BASE_SEPOLIA_CHAIN_ID), + }); + return { + pub: baseClients.publicClient as PublicClient, + wallet: baseClients.walletClient as WalletClient, + creditsAddress: baseUsdcCreditsAddress, + envConfig: { + ...environmentConfig, + chainID: BigInt(BASE_SEPOLIA_CHAIN_ID), + defaultRPCURL: baseRPCURL, + }, + }; + } + return { + pub: publicClient, + wallet: walletClient, + creditsAddress: usdcCreditsAddress, + envConfig: environmentConfig, + }; + } const module: BillingModule = { address, - async getTopUpInfo(): Promise { - const usdcAddress = await publicClient.readContract({ - address: usdcCreditsAddress, + async getTopUpInfo(opts?: { chain?: BillingChain }): Promise { + const { pub, creditsAddress } = resolveChainConfig(opts?.chain); + + const usdcAddress = await pub.readContract({ + address: creditsAddress, abi: USDCCreditsABI, functionName: "usdc", }) as Address; const [minimumPurchase, usdcBalance, currentAllowance] = await Promise.all([ - publicClient.readContract({ - address: usdcCreditsAddress, + pub.readContract({ + address: creditsAddress, abi: USDCCreditsABI, functionName: "minimumPurchase", }) as Promise, - publicClient.readContract({ + pub.readContract({ address: usdcAddress, abi: ERC20ABI, functionName: "balanceOf", args: [address], }) as Promise, - publicClient.readContract({ + pub.readContract({ address: usdcAddress, abi: ERC20ABI, functionName: "allowance", - args: [address, usdcCreditsAddress], + args: [address, creditsAddress], }) as Promise, ]); @@ -135,13 +179,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule { functionName: "topUp", skipTelemetry, - properties: { amount: opts.amount.toString() }, + properties: { amount: opts.amount.toString(), chain: opts.chain || "ethereum" }, }, async () => { const targetAccount = opts.account ?? address; + const { pub, wallet, creditsAddress, envConfig } = resolveChainConfig(opts.chain); // Read on-chain state - const { usdcAddress, currentAllowance } = await module.getTopUpInfo(); + const { usdcAddress, currentAllowance } = await module.getTopUpInfo({ chain: opts.chain }); // Build executions array const executions: Execution[] = []; @@ -154,14 +199,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule callData: encodeFunctionData({ abi: ERC20ABI, functionName: "approve", - args: [usdcCreditsAddress, opts.amount], + args: [creditsAddress, opts.amount], }), }); } // Always include purchaseCreditsFor executions.push({ - target: usdcCreditsAddress, + target: creditsAddress, value: 0n, callData: encodeFunctionData({ abi: USDCCreditsABI, @@ -172,9 +217,9 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule const txHash = await executeBatch( { - walletClient, - publicClient, - environmentConfig, + walletClient: wallet, + publicClient: pub, + environmentConfig: envConfig, executions, pendingMessage: "Submitting credit purchase...", }, @@ -299,6 +344,10 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule async purchaseCredits(amountCents: number, paymentMethodId?: string) { return billingApi.purchaseCredits(amountCents, paymentMethodId); }, + + hasBaseSupport(): boolean { + return !!baseUsdcCreditsAddress && !!baseRPCURL; + }, }; return module; From ed1d78df254a03992364c6fa553b370eddf90d4e Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:55:00 -0500 Subject: [PATCH 12/55] feat: export BillingChain type and BASE_SEPOLIA_CHAIN_ID from SDK --- packages/sdk/src/client/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 9a3b1fd2..7217a6db 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -62,6 +62,10 @@ export { createBillingModule, type BillingModule, type BillingModuleConfig, + type BillingChain, + type TopUpOpts, + type TopUpResult, + type TopUpInfo, } from "./modules/billing"; // Export environment config utilities @@ -72,6 +76,7 @@ export { getBuildType, isMainnet, getBillingEnvironmentConfig, + BASE_SEPOLIA_CHAIN_ID, } from "./common/config/environment"; export { isSubscriptionActive } from "./common/utils/billing"; From 68fcd6b9675445c93030777ef3d8a945877fc0d8 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:57:12 -0500 Subject: [PATCH 13/55] feat: add chain selection prompt for USDC top-up (Ethereum/Base) --- packages/cli/src/commands/billing/top-up.ts | 26 +++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 2708dbae..be54e931 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -20,6 +20,7 @@ import chalk from "chalk"; import { input, select } from "@inquirer/prompts"; import open from "open"; import { withTelemetry } from "../../telemetry"; +import { type BillingChain } from "@layr-labs/ecloud-sdk"; const POLL_INTERVAL_MS = 5_000; const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes @@ -55,6 +56,11 @@ export default class BillingTopUp extends Command { options: ["compute"], env: "ECLOUD_PRODUCT_ID", }), + chain: Flags.string({ + required: false, + description: "Blockchain network for USDC payment: ethereum or base", + options: ["ethereum", "base"], + }), }; async run() { @@ -112,15 +118,30 @@ export default class BillingTopUp extends Command { targetAccount: Address, baselineTotal: number | undefined, ) { - const onChainState = await billing.getTopUpInfo(); + let selectedChain: BillingChain = "ethereum"; + + if (billing.hasBaseSupport()) { + selectedChain = + (flags.chain as BillingChain) ?? + (await select({ + message: "Which network?", + choices: [ + { value: "ethereum", name: "Ethereum" }, + { value: "base", name: "Base" }, + ], + })); + } + + const onChainState = await billing.getTopUpInfo({ chain: selectedChain }); const { usdcBalance, minimumPurchase } = onChainState; const balanceFormatted = formatUnits(usdcBalance, 6); this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); if (usdcBalance === BigInt(0)) { + const networkName = selectedChain === "base" ? "Base Sepolia" : "Sepolia"; this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); - this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Send USDC on ${networkName} to: ${chalk.cyan(walletAddress)}`); this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); return; } @@ -159,6 +180,7 @@ export default class BillingTopUp extends Command { const { txHash } = await billing.topUp({ amount: amountRaw, account: targetAccount, + chain: selectedChain, }); this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); From f4e61a072e9cad133fa9c3c30ee10bbb9b7d6cf9 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 12:59:05 -0500 Subject: [PATCH 14/55] test: add chain selection tests for Base USDC top-up --- .../commands/billing/__tests__/top-up.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index cdb4fcc8..66174a9b 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -33,6 +33,7 @@ describe("ecloud billing top-up", () => { topUp: ReturnType; getPaymentMethods: ReturnType; purchaseCredits: ReturnType; + hasBaseSupport: ReturnType; }; beforeEach(() => { @@ -46,7 +47,9 @@ describe("ecloud billing top-up", () => { topUp: vi.fn(), getPaymentMethods: vi.fn(), purchaseCredits: vi.fn(), + hasBaseSupport: vi.fn(), }; + mockBilling.hasBaseSupport.mockReturnValue(false); (createBillingClient as ReturnType).mockResolvedValue(mockBilling); (input as ReturnType).mockResolvedValue("50"); @@ -123,6 +126,7 @@ describe("ecloud billing top-up", () => { expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: WALLET_ADDRESS, + chain: "ethereum", }); }); @@ -170,6 +174,7 @@ describe("ecloud billing top-up", () => { expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: targetAccount, + chain: "ethereum", }); }); @@ -224,6 +229,85 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Credits haven't appeared yet"); }); + it("usdc: prompts for chain selection when Base is available", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + (select as unknown as ReturnType).mockResolvedValue("base"); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which network?", + choices: expect.arrayContaining([ + expect.objectContaining({ value: "base" }), + expect.objectContaining({ value: "ethereum" }), + ]), + }), + ); + + expect(mockBilling.getTopUpInfo).toHaveBeenCalledWith({ chain: "base" }); + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); + }); + + it("usdc: skips chain prompt when Base is not configured", async () => { + mockBilling.hasBaseSupport.mockReturnValue(false); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", + }); + }); + + it("usdc: --chain flag skips network prompt", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc", chain: "base" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); + }); + // ── Credit Card Tests ── it("credit card: charges selected card on file", async () => { From 1f55c5b67cb55a0d81ddb751e657eb369847678b Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Wed, 6 May 2026 14:06:03 -0500 Subject: [PATCH 15/55] doc: execution plan --- .../plans/2026-05-06-base-chain-usdc-topup.md | 670 ++++++++++++++++++ 1 file changed, 670 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md diff --git a/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md b/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md new file mode 100644 index 00000000..c85c0b57 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md @@ -0,0 +1,670 @@ +# Base Chain USDC Top-Up Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to purchase credits via USDC on Base (Base Sepolia for now) in addition to Ethereum, with a chain selection prompt when both are available. + +**Architecture:** Add optional Base config fields (`baseUsdcCreditsAddress`, `baseRPCURL`) to `EnvironmentConfig`. The billing module creates chain-specific viem clients internally when `chain: "base"` is passed. The CLI prompts for chain selection only when Base is configured for the current environment. + +**Tech Stack:** TypeScript, viem (already has `baseSepolia` chain), vitest + +--- + +### Task 1: Add Base chain constants and config fields + +**Files:** +- Modify: `packages/sdk/src/client/common/constants.ts` +- Modify: `packages/sdk/src/client/common/types/index.ts` +- Modify: `packages/sdk/src/client/common/config/environment.ts` + +- [ ] **Step 1: Add Base Sepolia to SUPPORTED_CHAINS** + +In `packages/sdk/src/client/common/constants.ts`, add the `baseSepolia` import and include it in `SUPPORTED_CHAINS`: + +```typescript +import { sepolia, mainnet, baseSepolia } from "viem/chains"; + +export const SUPPORTED_CHAINS = [mainnet, sepolia, baseSepolia] as const; +``` + +- [ ] **Step 2: Add Base fields to EnvironmentConfig type** + +In `packages/sdk/src/client/common/types/index.ts`, add optional Base fields to the `EnvironmentConfig` interface: + +```typescript +export interface EnvironmentConfig { + name: string; + build: "dev" | "prod"; + chainID: bigint; + appControllerAddress: Address; + permissionControllerAddress: string; + erc7702DelegatorAddress: string; + kmsServerURL: string; + userApiServerURL: string; + defaultRPCURL: string; + billingRPCURL?: string; + usdcCreditsAddress?: Address; + baseUsdcCreditsAddress?: Address; + baseRPCURL?: string; +} +``` + +- [ ] **Step 3: Add Base Sepolia chain ID constant and populate config** + +In `packages/sdk/src/client/common/config/environment.ts`, add the chain ID constant and populate the `sepolia-dev` and `sepolia` environments: + +```typescript +export const BASE_SEPOLIA_CHAIN_ID = 84532; +``` + +Add to `sepolia-dev` environment object: +```typescript +baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", +baseRPCURL: "https://base-sepolia-rpc.publicnode.com", +``` + +Add to `sepolia` environment object: +```typescript +baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", +baseRPCURL: "https://base-sepolia-rpc.publicnode.com", +``` + +Do NOT add these to `mainnet-alpha` (not deployed yet). + +- [ ] **Step 4: Verify the SDK builds** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk/src/client/common/constants.ts packages/sdk/src/client/common/types/index.ts packages/sdk/src/client/common/config/environment.ts +git commit -m "feat: add Base Sepolia chain config for USDC credit purchases" +``` + +--- + +### Task 2: Add BillingChain type and update TopUpOpts/TopUpInfo + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts` + +- [ ] **Step 1: Add BillingChain type and update interfaces** + +At the top of `packages/sdk/src/client/modules/billing/index.ts` (after imports), add the chain type and update the opts/info interfaces: + +```typescript +export type BillingChain = "ethereum" | "base"; + +export interface TopUpOpts { + amount: bigint; + account?: Address; + chain?: BillingChain; +} + +export interface TopUpInfo { + usdcAddress: Address; + minimumPurchase: bigint; + usdcBalance: bigint; + currentAllowance: bigint; +} +``` + +Also add a new method to the `BillingModule` interface: + +```typescript +export interface BillingModule { + address: Address; + subscribe: (opts?: SubscriptionOpts) => Promise; + getStatus: (opts?: SubscriptionOpts) => Promise; + cancel: (opts?: SubscriptionOpts) => Promise; + getTopUpInfo: (opts?: { chain?: BillingChain }) => Promise; + topUp: (opts: TopUpOpts) => Promise; + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; + hasBaseSupport: () => boolean; +} +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: Errors about implementation not matching interface (expected — we'll fix in next task) + +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts +git commit -m "feat: add BillingChain type and hasBaseSupport to billing module interface" +``` + +--- + +### Task 3: Implement chain-aware getTopUpInfo and topUp + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts` +- Modify: `packages/cli/src/client.ts` + +- [ ] **Step 1: Add privateKey to BillingModuleConfig and update createBillingModule signature** + +In `packages/sdk/src/client/modules/billing/index.ts`, update `BillingModuleConfig`: + +```typescript +export interface BillingModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + skipTelemetry?: boolean; + publicClient: PublicClient; + environment: string; + privateKey?: Hex; +} +``` + +Add `Hex` to the existing viem import if not already there. Update the destructuring: + +```typescript +const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment, privateKey } = config; +``` + +- [ ] **Step 2: Pass privateKey from CLI createBillingClient** + +In `packages/cli/src/client.ts`, update `createBillingClient` to pass the private key through: + +```typescript +return createBillingModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + skipTelemetry: true, + privateKey: privateKey as Hex, +}); +``` + +- [ ] **Step 3: Add helper to resolve chain-specific clients and config** + +Add these imports at the top of `packages/sdk/src/client/modules/billing/index.ts`: + +```typescript +import { createClients } from "../../common/utils/helpers"; +import { BASE_SEPOLIA_CHAIN_ID } from "../../common/config/environment"; +``` + +Then inside `createBillingModule`, after the existing `usdcCreditsAddress` resolution block, add: + +```typescript +const baseUsdcCreditsAddress = environmentConfig.baseUsdcCreditsAddress; +const baseRPCURL = environmentConfig.baseRPCURL; + +function resolveChainConfig(chain?: BillingChain) { + if (chain === "base") { + if (!baseUsdcCreditsAddress || !baseRPCURL) { + throw new Error(`Base chain not configured for environment "${environment}"`); + } + if (!privateKey) { + throw new Error("Private key required for Base chain transactions"); + } + const baseClients = createClients({ + privateKey, + rpcUrl: baseRPCURL, + chainId: BigInt(BASE_SEPOLIA_CHAIN_ID), + }); + return { + pub: baseClients.publicClient as PublicClient, + wallet: baseClients.walletClient as WalletClient, + creditsAddress: baseUsdcCreditsAddress, + envConfig: { + ...environmentConfig, + chainID: BigInt(BASE_SEPOLIA_CHAIN_ID), + defaultRPCURL: baseRPCURL, + }, + }; + } + return { + pub: publicClient, + wallet: walletClient, + creditsAddress: usdcCreditsAddress, + envConfig: environmentConfig, + }; +} +``` + +- [ ] **Step 4: Update getTopUpInfo to accept chain option** + +Replace the existing `getTopUpInfo` method with: + +```typescript +async getTopUpInfo(opts?: { chain?: BillingChain }): Promise { + const { pub, creditsAddress } = resolveChainConfig(opts?.chain); + + const usdcAddress = await pub.readContract({ + address: creditsAddress, + abi: USDCCreditsABI, + functionName: "usdc", + }) as Address; + + const [minimumPurchase, usdcBalance, currentAllowance] = await Promise.all([ + pub.readContract({ + address: creditsAddress, + abi: USDCCreditsABI, + functionName: "minimumPurchase", + }) as Promise, + pub.readContract({ + address: usdcAddress, + abi: ERC20ABI, + functionName: "balanceOf", + args: [address], + }) as Promise, + pub.readContract({ + address: usdcAddress, + abi: ERC20ABI, + functionName: "allowance", + args: [address, creditsAddress], + }) as Promise, + ]); + + return { usdcAddress, minimumPurchase, usdcBalance, currentAllowance }; +}, +``` + +- [ ] **Step 5: Update topUp to use chain-specific clients** + +Replace the existing `topUp` method with: + +```typescript +async topUp(opts: TopUpOpts): Promise { + return withSDKTelemetry( + { + functionName: "topUp", + skipTelemetry, + properties: { amount: opts.amount.toString(), chain: opts.chain || "ethereum" }, + }, + async () => { + const targetAccount = opts.account ?? address; + const { pub, wallet, creditsAddress, envConfig } = resolveChainConfig(opts.chain); + + const { usdcAddress, currentAllowance } = await module.getTopUpInfo({ chain: opts.chain }); + + const executions: Execution[] = []; + + if (currentAllowance < opts.amount) { + executions.push({ + target: usdcAddress, + value: 0n, + callData: encodeFunctionData({ + abi: ERC20ABI, + functionName: "approve", + args: [creditsAddress, opts.amount], + }), + }); + } + + executions.push({ + target: creditsAddress, + value: 0n, + callData: encodeFunctionData({ + abi: USDCCreditsABI, + functionName: "purchaseCreditsFor", + args: [opts.amount, targetAccount], + }), + }); + + const txHash = await executeBatch( + { + walletClient: wallet, + publicClient: pub, + environmentConfig: envConfig, + executions, + pendingMessage: "Submitting credit purchase...", + }, + logger, + ); + + return { txHash, walletAddress: address }; + }, + ); +}, +``` + +- [ ] **Step 6: Implement hasBaseSupport** + +Add after the `purchaseCredits` method: + +```typescript +hasBaseSupport(): boolean { + return !!baseUsdcCreditsAddress && !!baseRPCURL; +}, +``` + +- [ ] **Step 7: Verify the SDK builds** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts packages/cli/src/client.ts +git commit -m "feat: implement chain-aware getTopUpInfo and topUp for Base support" +``` + +--- + +### Task 4: Export BillingChain from SDK package + +**Files:** +- Modify: `packages/sdk/src/client/index.ts` + +- [ ] **Step 1: Export BillingChain type from SDK entry point** + +In `packages/sdk/src/client/index.ts`, find the billing module exports and add `BillingChain`: + +```typescript +export { createBillingModule, type BillingModule, type BillingModuleConfig, type TopUpOpts, type TopUpResult, type TopUpInfo, type BillingChain } from "./modules/billing"; +``` + +If the export already exists as a group, just add `type BillingChain` to it. + +- [ ] **Step 2: Also export BASE_SEPOLIA_CHAIN_ID** + +Add to the environment config exports: + +```typescript +export { getEnvironmentConfig, getBillingEnvironmentConfig, getBuildType, getAvailableEnvironments, isEnvironmentAvailable, isMainnet, detectEnvironmentFromChainID, BASE_SEPOLIA_CHAIN_ID } from "./common/config/environment"; +``` + +- [ ] **Step 3: Verify build** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/index.ts +git commit -m "feat: export BillingChain type and BASE_SEPOLIA_CHAIN_ID from SDK" +``` + +--- + +### Task 5: Add chain selection to CLI top-up command + +**Files:** +- Modify: `packages/cli/src/commands/billing/top-up.ts` + +- [ ] **Step 1: Add chain flag and import BillingChain type** + +Add a new optional `--chain` flag to `BillingTopUp.flags`: + +```typescript +chain: Flags.string({ + required: false, + description: "Blockchain network for USDC payment: ethereum or base", + options: ["ethereum", "base"], +}), +``` + +Add the `BillingChain` import at the top: + +```typescript +import { type BillingChain } from "@layr-labs/ecloud-sdk"; +``` + +- [ ] **Step 2: Add chain selection prompt in handleUsdc** + +In the `handleUsdc` method, add chain selection logic BEFORE calling `getTopUpInfo`. Insert after the method signature and before `const onChainState = await billing.getTopUpInfo();`: + +```typescript +let selectedChain: BillingChain = "ethereum"; + +if (billing.hasBaseSupport()) { + selectedChain = + (flags.chain as BillingChain) ?? + (await select({ + message: "Which network?", + choices: [ + { value: "ethereum", name: "Ethereum" }, + { value: "base", name: "Base" }, + ], + })); +} +``` + +- [ ] **Step 3: Pass chain to getTopUpInfo and topUp calls** + +Update the `getTopUpInfo` call: + +```typescript +const onChainState = await billing.getTopUpInfo({ chain: selectedChain }); +``` + +Update the `topUp` call: + +```typescript +const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + chain: selectedChain, +}); +``` + +- [ ] **Step 4: Update the "No USDC" message to be chain-aware** + +Replace the zero-balance message block: + +```typescript +if (usdcBalance === BigInt(0)) { + const networkName = selectedChain === "base" ? "Base Sepolia" : "Sepolia"; + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on ${networkName} to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; +} +``` + +- [ ] **Step 5: Verify CLI builds** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/commands/billing/top-up.ts +git commit -m "feat: add chain selection prompt for USDC top-up (Ethereum/Base)" +``` + +--- + +### Task 6: Write tests for chain selection in CLI top-up + +**Files:** +- Modify: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +- [ ] **Step 1: Add hasBaseSupport to mock billing object** + +In the `mockBilling` setup in `beforeEach`, add the new method: + +```typescript +mockBilling = { + address: WALLET_ADDRESS, + getStatus: vi.fn(), + getTopUpInfo: vi.fn(), + topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + hasBaseSupport: vi.fn(), +}; +``` + +Update the type annotation for `mockBilling` to include it: + +```typescript +let mockBilling: { + address: string; + getStatus: ReturnType; + getTopUpInfo: ReturnType; + topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + hasBaseSupport: ReturnType; +}; +``` + +By default in `beforeEach`, set `hasBaseSupport` to return false so existing tests are unaffected: + +```typescript +mockBilling.hasBaseSupport.mockReturnValue(false); +``` + +- [ ] **Step 2: Update existing topUp assertions to include chain field** + +The existing tests assert `mockBilling.topUp` was called with `{ amount, account }`. Now the CLI always passes `chain: "ethereum"` (the default). Update ALL existing `expect(mockBilling.topUp).toHaveBeenCalledWith(...)` assertions to include `chain: "ethereum"`: + +```typescript +// Before: +expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, +}); + +// After: +expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", +}); +``` + +Also update any `expect(mockBilling.getTopUpInfo).toHaveBeenCalled()` assertions to expect `{ chain: "ethereum" }` if they check arguments. + +- [ ] **Step 3: Add test - prompts for chain when Base is available** + +```typescript +it("usdc: prompts for chain selection when Base is available", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + (select as unknown as ReturnType).mockResolvedValue("base"); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which network?", + choices: expect.arrayContaining([ + expect.objectContaining({ value: "base" }), + expect.objectContaining({ value: "ethereum" }), + ]), + }), + ); + + expect(mockBilling.getTopUpInfo).toHaveBeenCalledWith({ chain: "base" }); + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); +}); +``` + +- [ ] **Step 4: Add test - skips chain prompt when Base is NOT available** + +```typescript +it("usdc: skips chain prompt when Base is not configured", async () => { + mockBilling.hasBaseSupport.mockReturnValue(false); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", + }); +}); +``` + +- [ ] **Step 5: Add test - --chain flag skips prompt** + +```typescript +it("usdc: --chain flag skips network prompt", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc", chain: "base" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); +}); +``` + +- [ ] **Step 6: Run tests** + +Run: `cd packages/cli && npx vitest run src/commands/billing/__tests__/top-up.test.ts` +Expected: All tests pass (existing + new) + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/commands/billing/__tests__/top-up.test.ts +git commit -m "test: add chain selection tests for Base USDC top-up" +``` + +--- + +### Task 7: Verify end-to-end flow compiles and tests pass + +**Files:** None (verification only) + +- [ ] **Step 1: Full SDK type check** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 2: Full CLI type check** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Run all CLI billing tests** + +Run: `cd packages/cli && npx vitest run src/commands/billing/__tests__/` +Expected: All tests pass + +- [ ] **Step 4: Verify existing non-billing tests still pass** + +Run: `cd packages/cli && npx vitest run` +Expected: All tests pass (no regressions) From 34e33a9c584fbc3bd27daf1f6f13edc6f3fe6f29 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:24:55 -0500 Subject: [PATCH 16/55] feat(sdk): add admin and coupon API methods to BillingApiClient --- packages/sdk/src/client/common/types/index.ts | 44 +++++++++++ .../sdk/src/client/common/utils/billingapi.ts | 75 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c16a7785..e3ec18dd 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -467,3 +467,47 @@ export interface SequentialDeployResult { setPublicLogs?: Hex; }; } + +// Admin - Coupon types +export interface AdminCoupon { + id: string; + amountCents: number; + active: boolean; + redeemedBy: string; + redeemedAt: string | null; + createdBy: string; + createdAt: string; +} + +export interface CreateCouponResponse { + coupon: AdminCoupon; +} + +export interface ListCouponsResponse { + coupons: AdminCoupon[]; + total: number; +} + +export interface GetCouponResponse { + coupon: AdminCoupon; +} + +// Admin - Admin management types +export interface AdminUser { + id: string; + address: string; + createdAt: string; +} + +export interface AddAdminResponse { + admin: AdminUser; +} + +export interface ListAdminsResponse { + admins: AdminUser[]; +} + +// User-facing coupon redemption +export interface RedeemCouponResponse { + amountCents: number; +} diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 3edd3b3a..b767f22a 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -20,6 +20,12 @@ import { ProductSubscriptionResponse, PaymentMethodsResponse, CreditPurchaseResponse, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AddAdminResponse, + ListAdminsResponse, + RedeemCouponResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -199,6 +205,75 @@ export class BillingApiClient { return resp.json(); } + // ========================================================================== + // Admin - Coupon Methods + // ========================================================================== + + async createCoupon(amountCents: number): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { amountCents }); + return resp.json(); + } + + async listCoupons(opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }): Promise { + const params = new URLSearchParams(); + if (opts?.offset !== undefined) params.set("offset", opts.offset.toString()); + if (opts?.limit !== undefined) params.set("limit", opts.limit.toString()); + if (opts?.active !== undefined) params.set("active", opts.active.toString()); + if (opts?.redeemed !== undefined) params.set("redeemed", opts.redeemed.toString()); + const qs = params.toString(); + const endpoint = `${this.config.billingApiServerURL}/admin/coupons${qs ? `?${qs}` : ""}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async getCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async deactivateCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/deactivate`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute"); + } + + async redeemCouponForUser(id: string, address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/redeem`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + } + + // ========================================================================== + // Admin - Admin Management Methods + // ========================================================================== + + async addAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + return resp.json(); + } + + async removeAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins/${address}`; + await this.makeAuthenticatedRequest(endpoint, "DELETE", "compute"); + } + + async listAdmins(): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + // ========================================================================== + // User - Coupon Redemption + // ========================================================================== + + async redeemCoupon(code: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/coupons/redeem`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { code }); + return resp.json(); + } + // ========================================================================== // Internal Methods // ========================================================================== From c2cebc90de0214ff8ebbfc76c5eaadfa44740f35 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:27:46 -0500 Subject: [PATCH 17/55] feat(sdk): add AdminModule and redeemCoupon to BillingModule --- packages/cli/src/client.ts | 29 +++++++ packages/sdk/src/client/index.ts | 5 ++ .../sdk/src/client/modules/admin/index.ts | 77 +++++++++++++++++++ .../sdk/src/client/modules/billing/index.ts | 6 ++ 4 files changed, 117 insertions(+) create mode 100644 packages/sdk/src/client/modules/admin/index.ts diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 62771f3e..dfea37f8 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -2,6 +2,7 @@ import { createComputeModule, createBillingModule, createBuildModule, + createAdminModule, getEnvironmentConfig, requirePrivateKey, } from "@layr-labs/ecloud-sdk"; @@ -99,3 +100,31 @@ export async function createBuildClient(flags: CommonFlags) { skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry }); } + +export async function createAdminClient(flags: CommonFlags) { + flags = await validateCommonFlags(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.billingRPCURL || environmentConfig.defaultRPCURL; + const { key: privateKey, source } = await requirePrivateKey({ + privateKey: flags["private-key"], + }); + + if (flags.verbose) { + console.log(`Using private key from: ${source}`); + } + + const { walletClient, publicClient } = createViemClients({ + privateKey: privateKey as Hex, + rpcUrl, + environment, + }); + + return createAdminModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + }); +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 7217a6db..5f2e987d 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -67,6 +67,11 @@ export { type TopUpResult, type TopUpInfo, } from "./modules/billing"; +export { + createAdminModule, + type AdminModule, + type AdminModuleConfig, +} from "./modules/admin"; // Export environment config utilities export { diff --git a/packages/sdk/src/client/modules/admin/index.ts b/packages/sdk/src/client/modules/admin/index.ts new file mode 100644 index 00000000..aeaac1c5 --- /dev/null +++ b/packages/sdk/src/client/modules/admin/index.ts @@ -0,0 +1,77 @@ +import type { WalletClient, PublicClient, Address } from "viem"; +import { BillingApiClient } from "../../common/utils/billingapi"; +import { getBillingEnvironmentConfig, getBuildType } from "../../common/config/environment"; +import type { + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AddAdminResponse, + ListAdminsResponse, +} from "../../common/types"; + +export interface AdminModule { + address: Address; + createCoupon: (amountCents: number) => Promise; + listCoupons: (opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }) => Promise; + getCoupon: (id: string) => Promise; + deactivateCoupon: (id: string) => Promise; + redeemCouponForUser: (id: string, address: string) => Promise; + addAdmin: (address: string) => Promise; + removeAdmin: (address: string) => Promise; + listAdmins: () => Promise; +} + +export interface AdminModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + publicClient: PublicClient; + environment: string; +} + +export function createAdminModule(config: AdminModuleConfig): AdminModule { + const { verbose = false, walletClient } = config; + + if (!walletClient.account) { + throw new Error("WalletClient must have an account attached"); + } + const address = walletClient.account.address as Address; + + const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); + + return { + address, + + async createCoupon(amountCents: number) { + return billingApi.createCoupon(amountCents); + }, + + async listCoupons(opts?) { + return billingApi.listCoupons(opts); + }, + + async getCoupon(id: string) { + return billingApi.getCoupon(id); + }, + + async deactivateCoupon(id: string) { + return billingApi.deactivateCoupon(id); + }, + + async redeemCouponForUser(id: string, userAddress: string) { + return billingApi.redeemCouponForUser(id, userAddress); + }, + + async addAdmin(adminAddress: string) { + return billingApi.addAdmin(adminAddress); + }, + + async removeAdmin(adminAddress: string) { + return billingApi.removeAdmin(adminAddress); + }, + + async listAdmins() { + return billingApi.listAdmins(); + }, + }; +} diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index 3802250a..45e3ca5d 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -26,6 +26,7 @@ import type { ProductSubscriptionResponse, PaymentMethodsResponse, CreditPurchaseResponse, + RedeemCouponResponse, } from "../../common/types"; export type BillingChain = "ethereum" | "base"; @@ -64,6 +65,7 @@ export interface BillingModule { purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; /** Check if Base chain is configured for this environment */ hasBaseSupport: () => boolean; + redeemCoupon: (code: string) => Promise; } export interface BillingModuleConfig { @@ -348,6 +350,10 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule hasBaseSupport(): boolean { return !!baseUsdcCreditsAddress && !!baseRPCURL; }, + + async redeemCoupon(code: string) { + return billingApi.redeemCoupon(code); + }, }; return module; From 2ca49719a4c01c4ae64e21c22a1da40188371ee0 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:29:01 -0500 Subject: [PATCH 18/55] feat(cli): add billing redeem-coupon command --- .../cli/src/commands/billing/redeem-coupon.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/cli/src/commands/billing/redeem-coupon.ts diff --git a/packages/cli/src/commands/billing/redeem-coupon.ts b/packages/cli/src/commands/billing/redeem-coupon.ts new file mode 100644 index 00000000..4ed6a126 --- /dev/null +++ b/packages/cli/src/commands/billing/redeem-coupon.ts @@ -0,0 +1,43 @@ +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class BillingRedeemCoupon extends Command { + static description = "Redeem a coupon code for credits"; + + static examples = [ + "<%= config.bin %> billing redeem-coupon", + "<%= config.bin %> billing redeem-coupon --code ABC123", + ]; + + static flags = { + ...commonFlags, + code: Flags.string({ + required: false, + description: "Coupon code to redeem", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingRedeemCoupon); + const billing = await createBillingClient(flags); + + const code = + flags.code ?? + (await input({ + message: "Enter your coupon code:", + validate: (val) => (val.trim().length > 0 ? true : "Coupon code is required"), + })); + + const result = await billing.redeemCoupon(code.trim()); + const dollars = (result.amountCents / 100).toFixed(2); + + this.log(`\n ${chalk.green("✓")} Coupon redeemed! ${chalk.cyan(`$${dollars}`)} in credits added to your account.`); + this.log(`\n Run ${chalk.cyan("ecloud billing status")} to see your updated balance.\n`); + }); + } +} From b937c512c606a829422bcc24939a66aeac998771 Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:30:55 -0500 Subject: [PATCH 19/55] feat(cli): add admin coupons commands (create, list, get, deactivate, redeem) --- .../cli/src/commands/admin/coupons/create.ts | 50 +++++++++++++ .../src/commands/admin/coupons/deactivate.ts | 32 +++++++++ .../cli/src/commands/admin/coupons/get.ts | 42 +++++++++++ .../cli/src/commands/admin/coupons/list.ts | 71 +++++++++++++++++++ .../cli/src/commands/admin/coupons/redeem.ts | 36 ++++++++++ 5 files changed, 231 insertions(+) create mode 100644 packages/cli/src/commands/admin/coupons/create.ts create mode 100644 packages/cli/src/commands/admin/coupons/deactivate.ts create mode 100644 packages/cli/src/commands/admin/coupons/get.ts create mode 100644 packages/cli/src/commands/admin/coupons/list.ts create mode 100644 packages/cli/src/commands/admin/coupons/redeem.ts diff --git a/packages/cli/src/commands/admin/coupons/create.ts b/packages/cli/src/commands/admin/coupons/create.ts new file mode 100644 index 00000000..7d5b7026 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/create.ts @@ -0,0 +1,50 @@ +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class AdminCouponsCreate extends Command { + static description = "Create a new coupon"; + + static examples = [ + "<%= config.bin %> admin coupons create --amount 50", + ]; + + static flags = { + ...commonFlags, + amount: Flags.string({ + required: false, + description: "Coupon value in whole dollars", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsCreate); + const admin = await createAdminClient(flags); + + const amountStr = + flags.amount ?? + (await input({ + message: "Coupon value in dollars:", + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + return true; + }, + })); + + const dollars = parseFloat(amountStr); + const amountCents = Math.round(dollars * 100); + + const { coupon } = await admin.createCoupon(amountCents); + + this.log(`\n${chalk.green("✓")} Coupon created`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/deactivate.ts b/packages/cli/src/commands/admin/coupons/deactivate.ts new file mode 100644 index 00000000..025ac411 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/deactivate.ts @@ -0,0 +1,32 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsDeactivate extends Command { + static description = "Deactivate a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons deactivate ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsDeactivate); + const admin = await createAdminClient(flags); + + await admin.deactivateCoupon(args.id); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} deactivated.\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/get.ts b/packages/cli/src/commands/admin/coupons/get.ts new file mode 100644 index 00000000..7497d27d --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/get.ts @@ -0,0 +1,42 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsGet extends Command { + static description = "Get details of a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons get ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsGet); + const admin = await createAdminClient(flags); + + const { coupon } = await admin.getCoupon(args.id); + + this.log(`\n${chalk.bold("Coupon Details:")}`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}`); + this.log(` Created by: ${coupon.createdBy}`); + this.log(` Created at: ${coupon.createdAt}`); + if (coupon.redeemedBy) { + this.log(` Redeemed by: ${coupon.redeemedBy}`); + this.log(` Redeemed at: ${coupon.redeemedAt}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/list.ts b/packages/cli/src/commands/admin/coupons/list.ts new file mode 100644 index 00000000..42a07cb9 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/list.ts @@ -0,0 +1,71 @@ +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsList extends Command { + static description = "List coupons"; + + static examples = [ + "<%= config.bin %> admin coupons list", + "<%= config.bin %> admin coupons list --active", + "<%= config.bin %> admin coupons list --redeemed", + ]; + + static flags = { + ...commonFlags, + active: Flags.boolean({ + required: false, + description: "Filter to active coupons only", + }), + redeemed: Flags.boolean({ + required: false, + description: "Filter to redeemed coupons only", + }), + limit: Flags.integer({ + required: false, + description: "Number of results to return", + default: 25, + }), + offset: Flags.integer({ + required: false, + description: "Offset for pagination", + default: 0, + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsList); + const admin = await createAdminClient(flags); + + const opts: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean } = { + offset: flags.offset, + limit: flags.limit, + }; + if (flags.active) opts.active = true; + if (flags.redeemed) opts.redeemed = true; + + const { coupons, total } = await admin.listCoupons(opts); + + if (coupons.length === 0) { + this.log("\n No coupons found.\n"); + return; + } + + this.log(`\n${chalk.bold("Coupons")} (${coupons.length} of ${total}):\n`); + + for (const c of coupons) { + const value = `$${(c.amountCents / 100).toFixed(2)}`; + const status = c.redeemedBy + ? chalk.gray(`redeemed by ${c.redeemedBy}`) + : c.active + ? chalk.green("active") + : chalk.red("inactive"); + this.log(` ${chalk.cyan(c.id)} ${value} ${status}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/redeem.ts b/packages/cli/src/commands/admin/coupons/redeem.ts new file mode 100644 index 00000000..05c907fa --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/redeem.ts @@ -0,0 +1,36 @@ +import { Args, Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsRedeem extends Command { + static description = "Redeem a coupon for a user (admin action)"; + + static examples = [ + "<%= config.bin %> admin coupons redeem --address 0x...", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + address: Flags.string({ + required: true, + description: "User wallet address to redeem coupon for", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsRedeem); + const admin = await createAdminClient(flags); + + await admin.redeemCouponForUser(args.id, flags.address); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} redeemed for ${chalk.cyan(flags.address)}.\n`); + }); + } +} From d44558f08da8bcf93ed3be9e2b96d724f503efdb Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:32:02 -0500 Subject: [PATCH 20/55] feat(cli): add admin admins commands (add, remove, list) --- packages/cli/src/commands/admin/admins/add.ts | 34 +++++++++++++++++ .../cli/src/commands/admin/admins/list.ts | 37 +++++++++++++++++++ .../cli/src/commands/admin/admins/remove.ts | 32 ++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 packages/cli/src/commands/admin/admins/add.ts create mode 100644 packages/cli/src/commands/admin/admins/list.ts create mode 100644 packages/cli/src/commands/admin/admins/remove.ts diff --git a/packages/cli/src/commands/admin/admins/add.ts b/packages/cli/src/commands/admin/admins/add.ts new file mode 100644 index 00000000..ef7860ca --- /dev/null +++ b/packages/cli/src/commands/admin/admins/add.ts @@ -0,0 +1,34 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsAdd extends Command { + static description = "Add a new admin"; + + static examples = [ + "<%= config.bin %> admin admins add 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to grant admin", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsAdd); + const admin = await createAdminClient(flags); + + const { admin: newAdmin } = await admin.addAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin added`); + this.log(` Address: ${chalk.cyan(newAdmin.address)}`); + this.log(` ID: ${newAdmin.id}\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/admins/list.ts b/packages/cli/src/commands/admin/admins/list.ts new file mode 100644 index 00000000..ab11edd8 --- /dev/null +++ b/packages/cli/src/commands/admin/admins/list.ts @@ -0,0 +1,37 @@ +import { Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsList extends Command { + static description = "List all admins"; + + static examples = [ + "<%= config.bin %> admin admins list", + ]; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminAdminsList); + const admin = await createAdminClient(flags); + + const { admins } = await admin.listAdmins(); + + if (admins.length === 0) { + this.log("\n No admins found.\n"); + return; + } + + this.log(`\n${chalk.bold("Admins")} (${admins.length}):\n`); + for (const a of admins) { + this.log(` ${chalk.cyan(a.address)} ${chalk.gray(a.createdAt)}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/admins/remove.ts b/packages/cli/src/commands/admin/admins/remove.ts new file mode 100644 index 00000000..e4ebd587 --- /dev/null +++ b/packages/cli/src/commands/admin/admins/remove.ts @@ -0,0 +1,32 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsRemove extends Command { + static description = "Remove an admin"; + + static examples = [ + "<%= config.bin %> admin admins remove 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to remove from admins", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsRemove); + const admin = await createAdminClient(flags); + + await admin.removeAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin ${chalk.cyan(args.address)} removed.\n`); + }); + } +} From 3c10895f5cec22112a45ba7ad404d778c6dc0f6d Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Sun, 17 May 2026 22:32:58 -0500 Subject: [PATCH 21/55] feat(cli): register admin topics in oclif config --- packages/cli/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index a522a919..d72dd5ff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -80,6 +80,15 @@ "compute:env": { "hidden": true, "description": "Manage deployment environment [alias: env]" + }, + "admin": { + "description": "Admin operations (requires admin privileges)" + }, + "admin:coupons": { + "description": "Manage coupons" + }, + "admin:admins": { + "description": "Manage admin users" } } }, From bec561a3dd135e88143f240ed6626a3f2be280eb Mon Sep 17 00:00:00 2001 From: Sean McGary Date: Mon, 18 May 2026 09:19:54 -0500 Subject: [PATCH 22/55] chore: execution plan --- .../2026-05-17-admin-and-coupon-commands.md | 934 ++++++++++++++++++ 1 file changed, 934 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md diff --git a/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md b/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md new file mode 100644 index 00000000..c9a438ac --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md @@ -0,0 +1,934 @@ +# Admin & Coupon CLI Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `admin` command group (coupons + admins management) and a `billing redeem-coupon` command for users to redeem coupon codes for credits. + +**Architecture:** The billing API server already exposes admin (`/admin/coupons`, `/admin/admins`) and user-facing coupon (`/v1/coupons/redeem`) REST endpoints authenticated via EIP-712 signatures. We'll extend `BillingApiClient` in the SDK with these endpoint methods, create a new `AdminModule` in the SDK, add `redeemCoupon` to the existing `BillingModule`, then add CLI commands following the established oclif pattern. + +**Tech Stack:** TypeScript, oclif, viem (EIP-712 signatures), axios (HTTP), @inquirer/prompts, chalk + +--- + +### Task 1: Add Admin & Coupon API Methods to BillingApiClient + +**Files:** +- Modify: `packages/sdk/src/client/common/utils/billingapi.ts` +- Modify: `packages/sdk/src/client/common/types/index.ts` (or wherever billing types live) + +- [ ] **Step 1: Add types for admin and coupon API responses** + +First, find where billing types are defined: + +Run: `grep -r "ProductSubscriptionResponse" packages/sdk/src/client/common/types/ --include="*.ts" -l` + +Then add these types to the types file: + +```typescript +// Admin - Coupon types +export interface AdminCoupon { + id: string; + amountCents: number; + active: boolean; + redeemedBy: string; + redeemedAt: string | null; + createdBy: string; + createdAt: string; +} + +export interface CreateCouponResponse { + coupon: AdminCoupon; +} + +export interface ListCouponsResponse { + coupons: AdminCoupon[]; + total: number; +} + +export interface GetCouponResponse { + coupon: AdminCoupon; +} + +// Admin - Admin management types +export interface AdminUser { + id: string; + address: string; + createdAt: string; +} + +export interface AddAdminResponse { + admin: AdminUser; +} + +export interface ListAdminsResponse { + admins: AdminUser[]; +} + +// User-facing coupon redemption +export interface RedeemCouponResponse { + amountCents: number; +} +``` + +- [ ] **Step 2: Add admin and coupon methods to BillingApiClient** + +Add the following methods to `packages/sdk/src/client/common/utils/billingapi.ts`: + +```typescript +// ======================================================================== +// Admin - Coupon Methods +// ======================================================================== + +async createCoupon(amountCents: number): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { amountCents }); + return resp.json(); +} + +async listCoupons(opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }): Promise { + const params = new URLSearchParams(); + if (opts?.offset !== undefined) params.set("offset", opts.offset.toString()); + if (opts?.limit !== undefined) params.set("limit", opts.limit.toString()); + if (opts?.active !== undefined) params.set("active", opts.active.toString()); + if (opts?.redeemed !== undefined) params.set("redeemed", opts.redeemed.toString()); + const qs = params.toString(); + const endpoint = `${this.config.billingApiServerURL}/admin/coupons${qs ? `?${qs}` : ""}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +async getCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +async deactivateCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/deactivate`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute"); +} + +async redeemCouponForUser(id: string, address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/redeem`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); +} + +// ======================================================================== +// Admin - Admin Management Methods +// ======================================================================== + +async addAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + return resp.json(); +} + +async removeAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins/${address}`; + await this.makeAuthenticatedRequest(endpoint, "DELETE", "compute"); +} + +async listAdmins(): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +// ======================================================================== +// User - Coupon Redemption +// ======================================================================== + +async redeemCoupon(code: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/coupons/redeem`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { code }); + return resp.json(); +} +``` + +- [ ] **Step 3: Run typecheck to verify** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck` +Expected: PASS (no type errors) + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/common/utils/billingapi.ts packages/sdk/src/client/common/types/ +git commit -m "feat(sdk): add admin and coupon API methods to BillingApiClient" +``` + +--- + +### Task 2: Create AdminModule in the SDK + +**Files:** +- Create: `packages/sdk/src/client/modules/admin/index.ts` +- Modify: `packages/sdk/src/client/index.ts` +- Modify: `packages/cli/src/client.ts` + +- [ ] **Step 1: Create the AdminModule** + +Create `packages/sdk/src/client/modules/admin/index.ts`: + +```typescript +import type { WalletClient, PublicClient, Address } from "viem"; +import { BillingApiClient } from "../../common/utils/billingapi"; +import { getBillingEnvironmentConfig, getBuildType } from "../../common/config/environment"; +import type { + AdminCoupon, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AdminUser, + AddAdminResponse, + ListAdminsResponse, +} from "../../common/types"; + +export interface AdminModule { + address: Address; + createCoupon: (amountCents: number) => Promise; + listCoupons: (opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }) => Promise; + getCoupon: (id: string) => Promise; + deactivateCoupon: (id: string) => Promise; + redeemCouponForUser: (id: string, address: string) => Promise; + addAdmin: (address: string) => Promise; + removeAdmin: (address: string) => Promise; + listAdmins: () => Promise; +} + +export interface AdminModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + publicClient: PublicClient; + environment: string; +} + +export function createAdminModule(config: AdminModuleConfig): AdminModule { + const { verbose = false, walletClient } = config; + + if (!walletClient.account) { + throw new Error("WalletClient must have an account attached"); + } + const address = walletClient.account.address as Address; + + const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); + + return { + address, + + async createCoupon(amountCents: number) { + return billingApi.createCoupon(amountCents); + }, + + async listCoupons(opts?) { + return billingApi.listCoupons(opts); + }, + + async getCoupon(id: string) { + return billingApi.getCoupon(id); + }, + + async deactivateCoupon(id: string) { + return billingApi.deactivateCoupon(id); + }, + + async redeemCouponForUser(id: string, userAddress: string) { + return billingApi.redeemCouponForUser(id, userAddress); + }, + + async addAdmin(adminAddress: string) { + return billingApi.addAdmin(adminAddress); + }, + + async removeAdmin(adminAddress: string) { + return billingApi.removeAdmin(adminAddress); + }, + + async listAdmins() { + return billingApi.listAdmins(); + }, + }; +} +``` + +- [ ] **Step 2: Export AdminModule from SDK index** + +Add to `packages/sdk/src/client/index.ts`: + +```typescript +export { + createAdminModule, + type AdminModule, + type AdminModuleConfig, +} from "./modules/admin"; +``` + +- [ ] **Step 3: Add `redeemCoupon` to the BillingModule interface and implementation** + +In `packages/sdk/src/client/modules/billing/index.ts`, add to the `BillingModule` interface: + +```typescript +redeemCoupon: (code: string) => Promise; +``` + +And in the `createBillingModule` function's returned module object: + +```typescript +async redeemCoupon(code: string) { + return billingApi.redeemCoupon(code); +}, +``` + +Import `RedeemCouponResponse` from the types file. + +- [ ] **Step 4: Add `createAdminClient` to the CLI's client.ts** + +Add to `packages/cli/src/client.ts`: + +```typescript +import { + createComputeModule, + createBillingModule, + createBuildModule, + createAdminModule, + getEnvironmentConfig, + requirePrivateKey, +} from "@layr-labs/ecloud-sdk"; + +// ... existing code ... + +export async function createAdminClient(flags: CommonFlags) { + flags = await validateCommonFlags(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.billingRPCURL || environmentConfig.defaultRPCURL; + const { key: privateKey, source } = await requirePrivateKey({ + privateKey: flags["private-key"], + }); + + if (flags.verbose) { + console.log(`Using private key from: ${source}`); + } + + const { walletClient, publicClient } = createViemClients({ + privateKey: privateKey as Hex, + rpcUrl, + environment, + }); + + return createAdminModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + }); +} +``` + +- [ ] **Step 5: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/sdk/src/client/modules/admin/ packages/sdk/src/client/index.ts packages/sdk/src/client/modules/billing/index.ts packages/cli/src/client.ts +git commit -m "feat(sdk): add AdminModule and redeemCoupon to BillingModule" +``` + +--- + +### Task 3: Add `billing redeem-coupon` CLI Command + +**Files:** +- Create: `packages/cli/src/commands/billing/redeem-coupon.ts` + +- [ ] **Step 1: Create the redeem-coupon command** + +Create `packages/cli/src/commands/billing/redeem-coupon.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class BillingRedeemCoupon extends Command { + static description = "Redeem a coupon code for credits"; + + static examples = [ + "<%= config.bin %> billing redeem-coupon", + "<%= config.bin %> billing redeem-coupon --code ABC123", + ]; + + static flags = { + ...commonFlags, + code: Flags.string({ + required: false, + description: "Coupon code to redeem", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingRedeemCoupon); + const billing = await createBillingClient(flags); + + const code = + flags.code ?? + (await input({ + message: "Enter your coupon code:", + validate: (val) => (val.trim().length > 0 ? true : "Coupon code is required"), + })); + + const result = await billing.redeemCoupon(code.trim()); + const dollars = (result.amountCents / 100).toFixed(2); + + this.log(`\n ${chalk.green("✓")} Coupon redeemed! ${chalk.cyan(`$${dollars}`)} in credits added to your account.`); + this.log(`\n Run ${chalk.cyan("ecloud billing status")} to see your updated balance.\n`); + }); + } +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/commands/billing/redeem-coupon.ts +git commit -m "feat(cli): add billing redeem-coupon command" +``` + +--- + +### Task 4: Add `admin coupons` CLI Commands + +**Files:** +- Create: `packages/cli/src/commands/admin/coupons/create.ts` +- Create: `packages/cli/src/commands/admin/coupons/list.ts` +- Create: `packages/cli/src/commands/admin/coupons/get.ts` +- Create: `packages/cli/src/commands/admin/coupons/deactivate.ts` +- Create: `packages/cli/src/commands/admin/coupons/redeem.ts` + +- [ ] **Step 1: Create `admin coupons create`** + +Create `packages/cli/src/commands/admin/coupons/create.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class AdminCouponsCreate extends Command { + static description = "Create a new coupon"; + + static examples = [ + "<%= config.bin %> admin coupons create --amount 50", + ]; + + static flags = { + ...commonFlags, + amount: Flags.string({ + required: false, + description: "Coupon value in whole dollars", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsCreate); + const admin = await createAdminClient(flags); + + const amountStr = + flags.amount ?? + (await input({ + message: "Coupon value in dollars:", + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + return true; + }, + })); + + const dollars = parseFloat(amountStr); + const amountCents = Math.round(dollars * 100); + + const { coupon } = await admin.createCoupon(amountCents); + + this.log(`\n${chalk.green("✓")} Coupon created`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}\n`); + }); + } +} +``` + +- [ ] **Step 2: Create `admin coupons list`** + +Create `packages/cli/src/commands/admin/coupons/list.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsList extends Command { + static description = "List coupons"; + + static examples = [ + "<%= config.bin %> admin coupons list", + "<%= config.bin %> admin coupons list --active", + "<%= config.bin %> admin coupons list --redeemed", + ]; + + static flags = { + ...commonFlags, + active: Flags.boolean({ + required: false, + description: "Filter to active coupons only", + }), + redeemed: Flags.boolean({ + required: false, + description: "Filter to redeemed coupons only", + }), + limit: Flags.integer({ + required: false, + description: "Number of results to return", + default: 25, + }), + offset: Flags.integer({ + required: false, + description: "Offset for pagination", + default: 0, + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsList); + const admin = await createAdminClient(flags); + + const opts: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean } = { + offset: flags.offset, + limit: flags.limit, + }; + if (flags.active) opts.active = true; + if (flags.redeemed) opts.redeemed = true; + + const { coupons, total } = await admin.listCoupons(opts); + + if (coupons.length === 0) { + this.log("\n No coupons found.\n"); + return; + } + + this.log(`\n${chalk.bold("Coupons")} (${coupons.length} of ${total}):\n`); + + for (const c of coupons) { + const value = `$${(c.amountCents / 100).toFixed(2)}`; + const status = c.redeemedBy + ? chalk.gray(`redeemed by ${c.redeemedBy}`) + : c.active + ? chalk.green("active") + : chalk.red("inactive"); + this.log(` ${chalk.cyan(c.id)} ${value} ${status}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 3: Create `admin coupons get`** + +Create `packages/cli/src/commands/admin/coupons/get.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsGet extends Command { + static description = "Get details of a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons get ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsGet); + const admin = await createAdminClient(flags); + + const { coupon } = await admin.getCoupon(args.id); + + this.log(`\n${chalk.bold("Coupon Details:")}`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}`); + this.log(` Created by: ${coupon.createdBy}`); + this.log(` Created at: ${coupon.createdAt}`); + if (coupon.redeemedBy) { + this.log(` Redeemed by: ${coupon.redeemedBy}`); + this.log(` Redeemed at: ${coupon.redeemedAt}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 4: Create `admin coupons deactivate`** + +Create `packages/cli/src/commands/admin/coupons/deactivate.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsDeactivate extends Command { + static description = "Deactivate a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons deactivate ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsDeactivate); + const admin = await createAdminClient(flags); + + await admin.deactivateCoupon(args.id); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} deactivated.\n`); + }); + } +} +``` + +- [ ] **Step 5: Create `admin coupons redeem`** + +Create `packages/cli/src/commands/admin/coupons/redeem.ts`: + +```typescript +import { Args, Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsRedeem extends Command { + static description = "Redeem a coupon for a user (admin action)"; + + static examples = [ + "<%= config.bin %> admin coupons redeem --address 0x...", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + address: Flags.string({ + required: true, + description: "User wallet address to redeem coupon for", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsRedeem); + const admin = await createAdminClient(flags); + + await admin.redeemCouponForUser(args.id, flags.address); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} redeemed for ${chalk.cyan(flags.address)}.\n`); + }); + } +} +``` + +- [ ] **Step 6: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/commands/admin/coupons/ +git commit -m "feat(cli): add admin coupons commands (create, list, get, deactivate, redeem)" +``` + +--- + +### Task 5: Add `admin admins` CLI Commands + +**Files:** +- Create: `packages/cli/src/commands/admin/admins/add.ts` +- Create: `packages/cli/src/commands/admin/admins/remove.ts` +- Create: `packages/cli/src/commands/admin/admins/list.ts` + +- [ ] **Step 1: Create `admin admins add`** + +Create `packages/cli/src/commands/admin/admins/add.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsAdd extends Command { + static description = "Add a new admin"; + + static examples = [ + "<%= config.bin %> admin admins add 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to grant admin", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsAdd); + const admin = await createAdminClient(flags); + + const { admin: newAdmin } = await admin.addAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin added`); + this.log(` Address: ${chalk.cyan(newAdmin.address)}`); + this.log(` ID: ${newAdmin.id}\n`); + }); + } +} +``` + +- [ ] **Step 2: Create `admin admins remove`** + +Create `packages/cli/src/commands/admin/admins/remove.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsRemove extends Command { + static description = "Remove an admin"; + + static examples = [ + "<%= config.bin %> admin admins remove 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to remove from admins", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsRemove); + const admin = await createAdminClient(flags); + + await admin.removeAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin ${chalk.cyan(args.address)} removed.\n`); + }); + } +} +``` + +- [ ] **Step 3: Create `admin admins list`** + +Create `packages/cli/src/commands/admin/admins/list.ts`: + +```typescript +import { Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsList extends Command { + static description = "List all admins"; + + static examples = [ + "<%= config.bin %> admin admins list", + ]; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminAdminsList); + const admin = await createAdminClient(flags); + + const { admins } = await admin.listAdmins(); + + if (admins.length === 0) { + this.log("\n No admins found.\n"); + return; + } + + this.log(`\n${chalk.bold("Admins")} (${admins.length}):\n`); + for (const a of admins) { + this.log(` ${chalk.cyan(a.address)} ${chalk.gray(a.createdAt)}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 4: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/admin/admins/ +git commit -m "feat(cli): add admin admins commands (add, remove, list)" +``` + +--- + +### Task 6: Register `admin` Topics in package.json + +**Files:** +- Modify: `packages/cli/package.json` + +- [ ] **Step 1: Add admin topics to oclif config** + +In `packages/cli/package.json`, add to the `oclif.topics` object: + +```json +"admin": { + "description": "Admin operations (requires admin privileges)" +}, +"admin:coupons": { + "description": "Manage coupons" +}, +"admin:admins": { + "description": "Manage admin users" +} +``` + +- [ ] **Step 2: Verify CLI discovers commands** + +Run: `cd /Users/seanmcgary/Code/ecloud/packages/cli && pnpm run build && node bin/run.js admin --help` +Expected: Shows admin topic with coupons and admins sub-topics + +Run: `node bin/run.js admin coupons --help` +Expected: Lists create, list, get, deactivate, redeem commands + +Run: `node bin/run.js billing redeem-coupon --help` +Expected: Shows redeem-coupon command help + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/package.json +git commit -m "feat(cli): register admin topics in oclif config" +``` + +--- + +### Task 7: Export New Types from SDK + +**Files:** +- Modify: `packages/sdk/src/client/index.ts` + +- [ ] **Step 1: Ensure all new types are exported from SDK entrypoint** + +Verify that `RedeemCouponResponse` is exported via the existing `export * from "./common/types"` line. If admin types need explicit export (because they're in a new file), add: + +```typescript +export type { + AdminCoupon, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AdminUser, + AddAdminResponse, + ListAdminsResponse, + RedeemCouponResponse, +} from "./common/types"; +``` + +Also export `RedeemCouponResponse` from the billing module exports if needed: + +```typescript +export { + createBillingModule, + type BillingModule, + type BillingModuleConfig, + type BillingChain, + type TopUpOpts, + type TopUpResult, + type TopUpInfo, +} from "./modules/billing"; +``` + +- [ ] **Step 2: Final full typecheck and build** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck && pnpm --filter @layr-labs/ecloud-cli run typecheck && pnpm --filter @layr-labs/ecloud-cli run build` +Expected: All pass + +- [ ] **Step 3: Commit if any changes** + +```bash +git add packages/sdk/src/client/index.ts +git commit -m "feat(sdk): export admin and coupon types" +``` From 6467903e513c300e8409ab908441f45d21207d96 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 20 May 2026 18:23:34 -0300 Subject: [PATCH 23/55] fix(cli): spread commonFlags on billing cancel (RND-567) The `billing cancel` command was cherry-picking only `private-key` and `verbose` from commonFlags, leaving `--environment`, `--rpc-url`, `--max-fee-per-gas`, `--max-priority-fee`, and `--nonce` undeclared. This made the command unusable in CI/scripted flows because there was no non-interactive way to pick the environment. Spread `...commonFlags` like the other billing subcommands (status, subscribe, top-up, list-cards) so all common flags are accepted; keep `product` and `force` flags on top. --- packages/cli/src/commands/billing/cancel.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/billing/cancel.ts b/packages/cli/src/commands/billing/cancel.ts index c81276ee..1bc0b31b 100644 --- a/packages/cli/src/commands/billing/cancel.ts +++ b/packages/cli/src/commands/billing/cancel.ts @@ -10,8 +10,7 @@ export default class BillingCancel extends Command { static description = "Cancel subscription"; static flags = { - "private-key": commonFlags["private-key"], - verbose: commonFlags.verbose, + ...commonFlags, product: Flags.string({ required: false, description: "Product ID", From a05785ba23cd6432101a8592474a79f657835861 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 20 May 2026 18:32:59 -0300 Subject: [PATCH 24/55] fix(cli): bound upgrade watch with timeout and progress (RND-568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'ecloud compute app upgrade' command would hang indefinitely after submitting the on-chain transaction whenever the orchestrator was silent (15+ minutes observed in the wild) — there was no deadline, no progress between status transitions, and no recovery hint. - Bound watchUntilUpgradeComplete with a deadline (default 10 minutes, overridable via ECLOUD_WATCH_TIMEOUT_SECONDS env var or an explicit timeoutSeconds option). - Log every status transition on its own line with elapsed seconds, mirroring watchUntilRunning. - On timeout, throw a typed WatchUpgradeTimeoutError carrying appId, lastStatus, elapsedSeconds, and timeoutSeconds. - CLI catches the timeout, prints a recovery hint pointing the user to 'ecloud compute app info ' along with txHash and appId, and exits non-zero. Success path is unchanged. --- .../cli/src/commands/compute/app/upgrade.ts | 39 +++++++- .../sdk/src/client/common/contract/watcher.ts | 95 ++++++++++++++++++- packages/sdk/src/client/index.ts | 2 + .../src/client/modules/compute/app/index.ts | 15 ++- .../src/client/modules/compute/app/upgrade.ts | 6 ++ 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 90a3ad04..cc944ec4 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -1,5 +1,10 @@ import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + UserApiClient, + isMainnet, + WatchTimeoutError, +} from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; @@ -130,6 +135,11 @@ export default class AppUpgrade extends Command { description: "Skip all confirmation prompts", default: false, }), + "watch-timeout": Flags.integer({ + description: + "Maximum seconds to wait for the upgrade to complete before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", + }), }; async run() { @@ -407,7 +417,32 @@ export default class AppUpgrade extends Command { const res = await compute.app.executeUpgrade(prepared, finalTx); // 12. Watch until upgrade completes - await compute.app.watchUpgrade(res.appId); + try { + await compute.app.watchUpgrade(res.appId, { timeoutSeconds: flags["watch-timeout"] }); + } catch (err: any) { + if (err instanceof WatchTimeoutError) { + this.log(""); + this.log( + chalk.yellow( + `Timed out after ${err.elapsedSeconds}s waiting for upgrade to complete (last status: ${err.lastStatus ?? "unknown"}).`, + ), + ); + this.log(chalk.gray("The on-chain transaction was submitted; the orchestrator may")); + this.log(chalk.gray("still be processing. To check the current status, run:")); + this.log(""); + this.log(` ${chalk.cyan(`ecloud compute app info ${res.appId}`)}`); + this.log(""); + this.log(chalk.gray(`appId: ${res.appId}`)); + this.log(chalk.gray(`txHash: ${res.txHash}`)); + this.log( + chalk.gray( + `(override the watch deadline with ECLOUD_WATCH_TIMEOUT_SECONDS, currently ${err.timeoutSeconds}s)`, + ), + ); + this.exit(1); + } + throw err; + } try { const cwd = process.env.INIT_CWD || process.cwd(); diff --git a/packages/sdk/src/client/common/contract/watcher.ts b/packages/sdk/src/client/common/contract/watcher.ts index 3482ff00..2a139b9a 100644 --- a/packages/sdk/src/client/common/contract/watcher.ts +++ b/packages/sdk/src/client/common/contract/watcher.ts @@ -122,20 +122,83 @@ export interface WatchUntilUpgradeCompleteOptions { publicClient: PublicClient; environmentConfig: EnvironmentConfig; appId: Address; + /** + * Maximum time (in seconds) to wait for the upgrade to complete before + * throwing a {@link WatchTimeoutError}. If unspecified, defaults to + * the value of the `ECLOUD_WATCH_TIMEOUT_SECONDS` env var, falling back to + * {@link WATCH_DEFAULT_TIMEOUT_SECONDS}. + */ + timeoutSeconds?: number; } const APP_STATUS_STOPPED = "Stopped"; +/** Default upgrade watch timeout: 10 minutes. */ +export const WATCH_DEFAULT_TIMEOUT_SECONDS = 10 * 60; + +/** + * Error thrown when {@link watchUntilUpgradeComplete} exceeds its deadline + * without observing a terminal status (Stopped or Running) for the app. + * + * Callers (e.g. the CLI) can catch this and surface a recovery hint pointing + * the user at `ecloud compute app info `. + */ +export class WatchTimeoutError extends Error { + public readonly appId: Address; + public readonly lastStatus: string | undefined; + public readonly elapsedSeconds: number; + public readonly timeoutSeconds: number; + + constructor(params: { + appId: Address; + lastStatus: string | undefined; + elapsedSeconds: number; + timeoutSeconds: number; + }) { + super( + `Timed out after ${params.elapsedSeconds}s waiting for upgrade to complete (last status: ${ + params.lastStatus ?? "unknown" + })`, + ); + this.name = "WatchTimeoutError"; + this.appId = params.appId; + this.lastStatus = params.lastStatus; + this.elapsedSeconds = params.elapsedSeconds; + this.timeoutSeconds = params.timeoutSeconds; + } +} + +/** + * Resolve the upgrade watch timeout from explicit option, env var, or default. + */ +function resolveWatchTimeoutSeconds(explicit?: number): number { + if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) { + return explicit; + } + const fromEnv = process.env.ECLOUD_WATCH_TIMEOUT_SECONDS; + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return WATCH_DEFAULT_TIMEOUT_SECONDS; +} + /** * Watch app until upgrade completes * For upgrades, we watch until the app reaches Stopped status (upgrade complete) - * or Running status (if it was running before upgrade) + * or Running status (if it was running before upgrade). + * + * Throws {@link WatchTimeoutError} if the timeout elapses before a + * terminal status is observed. */ export async function watchUntilUpgradeComplete( options: WatchUntilUpgradeCompleteOptions, logger: Logger, ): Promise { const { walletClient, publicClient, environmentConfig, appId } = options; + const timeoutSeconds = resolveWatchTimeoutSeconds(options.timeoutSeconds); // Create UserAPI client const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient); @@ -193,7 +256,21 @@ export async function watchUntilUpgradeComplete( }; // Main watch loop + const startTime = Date.now(); + const deadline = startTime + timeoutSeconds * 1000; + let lastLoggedStatus: string | undefined; + let lastObservedStatus: string | undefined; while (true) { + if (Date.now() >= deadline) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + throw new WatchTimeoutError({ + appId, + lastStatus: lastObservedStatus, + elapsedSeconds, + timeoutSeconds, + }); + } + try { // Fetch app info const info = await userApiClient.getInfos([appId], 1); @@ -205,6 +282,14 @@ export async function watchUntilUpgradeComplete( const appInfo = info[0]; const currentStatus = appInfo.status; const currentIP = appInfo.ip || ""; + lastObservedStatus = currentStatus; + + // Log status changes and elapsed time on a new line per transition + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (currentStatus !== lastLoggedStatus) { + logger.info(`Status: ${currentStatus} (${elapsed}s)`); + lastLoggedStatus = currentStatus; + } // Check stop condition if (stopCondition(currentStatus, currentIP)) { @@ -214,6 +299,14 @@ export async function watchUntilUpgradeComplete( // Wait before next poll await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } catch (error: any) { + // Re-throw timeout errors and terminal failures from stopCondition. + if (error instanceof WatchTimeoutError) { + throw error; + } + // Heuristic: stopCondition throws plain Error for "Failed" state — let it propagate. + if (typeof error?.message === "string" && error.message.includes("Failed")) { + throw error; + } logger.warn(`Failed to fetch app info: ${error.message}`); await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 5f2e987d..c75237c5 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -47,7 +47,9 @@ export { executeUpgrade, watchUpgrade, type PrepareUpgradeResult, + type WatchUpgradeOptions, } from "./modules/compute/app/upgrade"; +export { WatchTimeoutError, WATCH_DEFAULT_TIMEOUT_SECONDS } from "./common/contract/watcher"; // Export compute module for standalone use export { diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index ccf6921a..d318e877 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -23,6 +23,7 @@ import { prepareUpgradeFromVerifiableBuild as prepareUpgradeFromVerifiableBuildFn, executeUpgrade as executeUpgradeFn, watchUpgrade as watchUpgradeFn, + type WatchUpgradeOptions, } from "./upgrade"; import { createApp, CreateAppOpts } from "./create"; import { logs, LogsOptions } from "./logs"; @@ -143,7 +144,7 @@ export interface AppModule { gasEstimate: GasEstimate; }>; executeUpgrade: (prepared: PreparedUpgrade, gas?: GasEstimate) => Promise; - watchUpgrade: (appId: AppId) => Promise; + watchUpgrade: (appId: AppId, opts?: WatchUpgradeOptions) => Promise; // Profile management setProfile: (appId: AppId, profile: AppProfile) => Promise; @@ -383,8 +384,16 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }; }, - async watchUpgrade(appId) { - return watchUpgradeFn(appId, walletClient, publicClient, environment, logger, skipTelemetry); + async watchUpgrade(appId, opts) { + return watchUpgradeFn( + appId, + walletClient, + publicClient, + environment, + logger, + skipTelemetry, + opts, + ); }, // Profile management diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index 4c857e9d..bf587079 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -543,6 +543,10 @@ export async function executeUpgrade(options: ExecuteUpgradeOptions): Promise { return withSDKTelemetry( { @@ -567,6 +572,7 @@ export async function watchUpgrade( publicClient, environmentConfig, appId: appId as Address, + timeoutSeconds: opts?.timeoutSeconds, }, logger, ); From 1213d5ca7f0016cde7622505403da5b1671a0ad0 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 20 May 2026 19:15:13 -0300 Subject: [PATCH 25/55] fix(cli): bound deploy watch with timeout and heartbeat (RND-569) watchUntilRunning previously only logged on status transitions, so when the orchestrator silently kept the app in Unknown the user saw a single "Status: Unknown (1s)" line forever (visible especially over non-TTY stdout where carriage-return overwrites are invisible). The loop also had no timeout, so the CLI would hang indefinitely. Add a 30s heartbeat that re-emits the current status with elapsed time, plus a configurable timeout (default 10 minutes, override via ECLOUD_WATCH_TIMEOUT_SECONDS) that throws a typed WatchTimeoutError. The CLI deploy command catches it and prints a hint pointing at 'ecloud compute app info ' before exiting non-zero. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 48 ++++++++++- .../sdk/src/client/common/contract/watcher.ts | 85 ++++++++++++++++++- packages/sdk/src/client/index.ts | 4 + .../src/client/modules/compute/app/deploy.ts | 6 ++ .../src/client/modules/compute/app/index.ts | 9 +- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 202b17b1..0fb15304 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -1,5 +1,10 @@ import { Command, Flags } from "@oclif/core"; -import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + UserApiClient, + isMainnet, + WatchTimeoutError, +} from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createComputeClient } from "../../../client"; @@ -147,6 +152,11 @@ export default class AppDeploy extends Command { description: "Skip all confirmation prompts", default: false, }), + "watch-timeout": Flags.integer({ + description: + "Maximum seconds to wait for the app to start before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", + }), }; async run() { @@ -533,7 +543,41 @@ export default class AppDeploy extends Command { } // 12. Watch until app is running - const ipAddress = await compute.app.watchDeployment(res.appId); + let ipAddress: string | undefined; + try { + ipAddress = await compute.app.watchDeployment(res.appId, { + timeoutSeconds: flags["watch-timeout"], + }); + } catch (watchErr: any) { + if (watchErr instanceof WatchTimeoutError) { + this.log( + `\n${chalk.yellow("⚠")} ${chalk.yellow( + `Deployment did not reach Running within ${watchErr.elapsedSeconds}s (last status: ${watchErr.lastStatus ?? "unknown"}).`, + )}`, + ); + this.log( + chalk.gray( + `The deploy transaction succeeded, but the orchestrator hasn't reported the app as Running yet.`, + ), + ); + this.log(chalk.gray(` appId: ${res.appId}`)); + if (res.txHash) { + this.log(chalk.gray(` txHash: ${res.txHash}`)); + } + this.log( + chalk.gray( + `Check progress later with: ${chalk.cyan(`ecloud compute app info ${res.appId}`)}`, + ), + ); + this.log( + chalk.gray( + `Override the watch timeout with the ${chalk.cyan("ECLOUD_WATCH_TIMEOUT_SECONDS")} environment variable.`, + ), + ); + this.exit(1); + } + throw watchErr; + } try { const cwd = process.env.INIT_CWD || process.cwd(); diff --git a/packages/sdk/src/client/common/contract/watcher.ts b/packages/sdk/src/client/common/contract/watcher.ts index 3482ff00..c6c7fea9 100644 --- a/packages/sdk/src/client/common/contract/watcher.ts +++ b/packages/sdk/src/client/common/contract/watcher.ts @@ -17,13 +17,70 @@ export interface WatchUntilRunningOptions { publicClient: PublicClient; environmentConfig: EnvironmentConfig; appId: Address; + /** + * Maximum seconds to wait before throwing {@link WatchTimeoutError}. + * Precedence: explicit value > `ECLOUD_WATCH_TIMEOUT_SECONDS` env var > 600s default. + */ + timeoutSeconds?: number; } const WATCH_POLL_INTERVAL_SECONDS = 5; +const WATCH_HEARTBEAT_INTERVAL_SECONDS = 30; +export const WATCH_DEFAULT_TIMEOUT_SECONDS = 10 * 60; const APP_STATUS_RUNNING = "Running"; const APP_STATUS_FAILED = "Failed"; // const APP_STATUS_DEPLOYING = 'Deploying'; +/** + * Typed error thrown when watch loops exceed their timeout budget. + * + * Callers (e.g. the CLI) can catch this specifically to surface a + * troubleshooting hint without treating it as a generic failure. + */ +export class WatchTimeoutError extends Error { + public readonly appId: string; + public readonly elapsedSeconds: number; + public readonly lastStatus: string | undefined; + public readonly timeoutSeconds: number; + + constructor(args: { + appId: string; + elapsedSeconds: number; + lastStatus: string | undefined; + timeoutSeconds: number; + message?: string; + }) { + super( + args.message ?? + `Timed out after ${args.elapsedSeconds}s waiting for app ${args.appId} (last status: ${args.lastStatus ?? "unknown"})`, + ); + this.name = "WatchTimeoutError"; + this.appId = args.appId; + this.elapsedSeconds = args.elapsedSeconds; + this.lastStatus = args.lastStatus; + this.timeoutSeconds = args.timeoutSeconds; + } +} + +/** + * Resolve the watch timeout in seconds, honoring the + * ECLOUD_WATCH_TIMEOUT_SECONDS environment override. + */ +function resolveWatchTimeoutSeconds(explicit?: number): number { + if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) { + return Math.floor(explicit); + } + const raw = process.env.ECLOUD_WATCH_TIMEOUT_SECONDS; + if (raw === undefined || raw === "") { + return WATCH_DEFAULT_TIMEOUT_SECONDS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return WATCH_DEFAULT_TIMEOUT_SECONDS; + } + return Math.floor(parsed); +} + /** * Watch app until it reaches Running status with IP address */ @@ -79,8 +136,23 @@ export async function watchUntilRunning( // Main watch loop const startTime = Date.now(); + const timeoutSeconds = resolveWatchTimeoutSeconds(options.timeoutSeconds); let lastLoggedStatus: string | undefined; + let lastHeartbeatAt = startTime; while (true) { + const elapsedMs = Date.now() - startTime; + const elapsed = Math.round(elapsedMs / 1000); + + // Bound the loop: surface a typed timeout so callers can hint the user. + if (elapsed >= timeoutSeconds) { + throw new WatchTimeoutError({ + appId, + elapsedSeconds: elapsed, + lastStatus: lastLoggedStatus, + timeoutSeconds, + }); + } + try { // Fetch app info const info = await userApiClient.getInfos([appId], 1); @@ -93,11 +165,16 @@ export async function watchUntilRunning( const currentStatus = appInfo.status; const currentIP = appInfo.ip || ""; - // Log status changes and elapsed time - const elapsed = Math.round((Date.now() - startTime) / 1000); + // Log status transitions, plus a periodic heartbeat so non-TTY + // stdout (where carriage-return updates are invisible) still shows + // progress when the status string is unchanged for a long time. if (currentStatus !== lastLoggedStatus) { logger.info(`Status: ${currentStatus} (${elapsed}s)`); lastLoggedStatus = currentStatus; + lastHeartbeatAt = Date.now(); + } else if (Date.now() - lastHeartbeatAt >= WATCH_HEARTBEAT_INTERVAL_SECONDS * 1000) { + logger.info(`Status: ${currentStatus} (${elapsed}s)`); + lastHeartbeatAt = Date.now(); } // Check stop condition @@ -108,6 +185,10 @@ export async function watchUntilRunning( // Wait before next poll await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } catch (error: any) { + // Re-throw typed terminal errors so the caller can react to them. + if (error instanceof WatchTimeoutError) { + throw error; + } logger.warn(`Failed to fetch app info: ${error.message}`); await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 5f2e987d..0e5dd8dc 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -39,6 +39,7 @@ export { executeDeploy, watchDeployment, type PrepareDeployResult, + type WatchDeploymentOptions, } from "./modules/compute/app/deploy"; export { SDKUpgradeOptions, @@ -112,6 +113,9 @@ export { type EstimateGasOptions, } from "./common/contract/caller"; +// Export watcher errors so callers can react to typed terminal failures. +export { WatchTimeoutError } from "./common/contract/watcher"; + // Export batch gas estimation and delegation check export { estimateBatchGas, diff --git a/packages/sdk/src/client/modules/compute/app/deploy.ts b/packages/sdk/src/client/modules/compute/app/deploy.ts index 2b5ea5d6..27ebcf0c 100644 --- a/packages/sdk/src/client/modules/compute/app/deploy.ts +++ b/packages/sdk/src/client/modules/compute/app/deploy.ts @@ -711,6 +711,10 @@ export async function executeDeploy(options: ExecuteDeployOptions): Promise { return withSDKTelemetry( { @@ -735,6 +740,7 @@ export async function watchDeployment( publicClient, environmentConfig, appId: appId as Address, + timeoutSeconds: opts?.timeoutSeconds, }, logger, ); diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index ccf6921a..59014caf 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -16,6 +16,7 @@ import { prepareDeployFromVerifiableBuild as prepareDeployFromVerifiableBuildFn, executeDeploy as executeDeployFn, watchDeployment as watchDeploymentFn, + type WatchDeploymentOptions, } from "./deploy"; import { upgrade as upgradeApp, @@ -125,7 +126,10 @@ export interface AppModule { gasEstimate: GasEstimate; }>; executeDeploy: (prepared: PreparedDeploy, gas?: GasEstimate) => Promise; - watchDeployment: (appId: AppId) => Promise; + watchDeployment: ( + appId: AppId, + opts?: WatchDeploymentOptions, + ) => Promise; // Granular upgrade control prepareUpgrade: ( @@ -314,7 +318,7 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }; }, - async watchDeployment(appId) { + async watchDeployment(appId, opts) { return watchDeploymentFn( appId, walletClient, @@ -322,6 +326,7 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { environment, logger, skipTelemetry, + opts, ); }, From 1159ede307ee6200ca565d9de91b5001388c3855 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 14:04:16 -0300 Subject: [PATCH 26/55] fix(sdk): sync AppController ABI to v1.5.x Release (containerPolicy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sepolia-dev AppController was upgraded to v1.5.x (eigenx-contracts KMS-006, PR #15), which added a 4th field `containerPolicy` to the on-chain `Release` struct. This changed the `createApp` selector from 0xa60daa8f to 0x5e92a19f (and likewise createAppWithIsolatedBilling / upgradeApp). The SDK still shipped the 3-field ABI, so every deploy/upgrade encoded the old selector and the upgraded contract reverted with empty revert data — opaque "execution reverted" with no decodable reason. Changes: - Replace the vendored AppController.json with the v1.5.x ABI generated from eigenx-contracts master (createApp/createAppWithIsolatedBilling/upgradeApp now take the 4-field Release; adds createEmptyApp, confirmUpgrade, etc.). All SDK-used functions remain present. - Add ContainerPolicy / EnvVar types + EMPTY_CONTAINER_POLICY default, and an optional `containerPolicy` field on Release (backwards-compatible: callers that omit it get an empty policy that preserves the image's own entrypoint/env). - Encode containerPolicy at both release-encoding sites in caller.ts (prepareDeployBatch / prepareUpgradeBatch) via a shared helper. - Add release-encoding regression test pinning the 0x5e92a19f selector and the 4-field encoding so this drift cannot silently return. Verified E2E on sepolia-dev: deploy now passes the on-chain createApp step (app created on-chain, status STARTED) where it previously bare-reverted. The follow-on "Failed state" during TEE provisioning is unrelated to this ABI fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/client/common/abis/AppController.json | 375 +++++++++++++++++- .../sdk/src/client/common/contract/caller.ts | 19 +- .../common/contract/release-encoding.test.ts | 93 +++++ packages/sdk/src/client/common/types/index.ts | 34 ++ 4 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/src/client/common/contract/release-encoding.test.ts diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index 4608a2ed..918b5beb 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -135,6 +135,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "confirmUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "createApp", @@ -187,6 +200,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -252,6 +321,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -265,6 +390,44 @@ ], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "createEmptyApp", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createEmptyAppWithIsolatedBilling", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "domainSeparator", @@ -299,26 +462,26 @@ }, { "type": "function", - "name": "getBillingType", + "name": "getAppCreator", "inputs": [ { "name": "app", "type": "address", - "internalType": "address" + "internalType": "contractIApp" } ], "outputs": [ { "name": "", - "type": "uint8", - "internalType": "uint8" + "type": "address", + "internalType": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppCreator", + "name": "getAppLatestReleaseBlockNumber", "inputs": [ { "name": "app", @@ -329,15 +492,15 @@ "outputs": [ { "name": "", - "type": "address", - "internalType": "address" + "type": "uint32", + "internalType": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppLatestReleaseBlockNumber", + "name": "getAppOperatorSetId", "inputs": [ { "name": "app", @@ -356,7 +519,7 @@ }, { "type": "function", - "name": "getAppOperatorSetId", + "name": "getAppPendingReleaseBlockNumber", "inputs": [ { "name": "app", @@ -433,6 +596,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -489,6 +657,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -545,6 +718,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -601,6 +779,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -611,6 +794,44 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getBillingAccount", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBillingType", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enumIAppController.BillingType" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getMaxActiveAppsPerUser", @@ -866,6 +1087,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -1061,6 +1338,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -1112,6 +1445,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "UpgradeConfirmed", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, { "type": "error", "name": "AccountHasActiveApps", @@ -1167,6 +1519,11 @@ "name": "MoreThanOneArtifact", "inputs": [] }, + { + "type": "error", + "name": "NoPendingUpgrade", + "inputs": [] + }, { "type": "error", "name": "SignatureExpired", diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 0999f9f0..1a004d67 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -31,12 +31,27 @@ import { DeployProgressCallback, SequentialDeployResult, } from "../types"; -import { Release } from "../types"; +import { Release, ContainerPolicy, EMPTY_CONTAINER_POLICY } from "../types"; import { getChainFromID } from "../utils/helpers"; import AppControllerABI from "../abis/AppController.json"; import PermissionControllerABI from "../abis/PermissionController.json"; +/** + * Build the viem-encodable `containerPolicy` tuple for the AppController + * `Release` struct (v1.5.0+). Falls back to an empty policy when the caller + * does not supply one, which preserves the image's own entrypoint/env. + */ +function containerPolicyForViem(policy: ContainerPolicy = EMPTY_CONTAINER_POLICY) { + return { + args: policy.args, + cmdOverride: policy.cmdOverride, + env: policy.env.map((e) => ({ key: e.key, value: e.value })), + envOverride: policy.envOverride.map((e) => ({ key: e.key, value: e.value })), + restartPolicy: policy.restartPolicy, + }; +} + /** * Gas estimation result */ @@ -254,6 +269,7 @@ export async function prepareDeployBatch( }, publicEnv: bytesToHex(release.publicEnv) as Hex, encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + containerPolicy: containerPolicyForViem(release.containerPolicy), }; const functionName = options.billTo === "app" ? "createAppWithIsolatedBilling" : "createApp"; @@ -750,6 +766,7 @@ export async function prepareUpgradeBatch( }, publicEnv: bytesToHex(release.publicEnv) as Hex, encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + containerPolicy: containerPolicyForViem(release.containerPolicy), }; const upgradeData = encodeFunctionData({ diff --git a/packages/sdk/src/client/common/contract/release-encoding.test.ts b/packages/sdk/src/client/common/contract/release-encoding.test.ts new file mode 100644 index 00000000..6d47b640 --- /dev/null +++ b/packages/sdk/src/client/common/contract/release-encoding.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { decodeFunctionData, encodeFunctionData, type Hex } from "viem"; +import AppControllerABI from "../abis/AppController.json"; +import { EMPTY_CONTAINER_POLICY, type ContainerPolicy } from "../types"; + +/** + * Regression guard for the AppController v1.5.x `Release` ABI. + * + * v1.5.0 (eigenx-contracts KMS-006) added a 4th field `containerPolicy` to the + * on-chain `Release` struct, which changed the `createApp` selector from + * 0xa60daa8f to 0x5e92a19f. The SDK had shipped the 3-field ABI, so every + * deploy/upgrade encoded the old selector and reverted with empty data against + * the upgraded contract. These tests pin the new selectors and the 4-field + * encoding so the drift cannot silently return. + */ +describe("AppController Release encoding (v1.5.x containerPolicy)", () => { + const sampleRelease = (containerPolicy?: ContainerPolicy) => ({ + rmsRelease: { + artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "docker.io/acme/app" }], + upgradeByTime: 4_000_000_000, + }, + publicEnv: "0x" as Hex, + encryptedEnv: "0x" as Hex, + containerPolicy: containerPolicy ?? EMPTY_CONTAINER_POLICY, + }); + + const SALT = `0x${"22".repeat(32)}` as Hex; + const APP = `0x${"33".repeat(20)}` as Hex; + + it("encodes createApp with the v1.5.x selector (4-field Release)", () => { + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "createApp", + args: [SALT, sampleRelease()], + }); + expect(data.slice(0, 10)).toBe("0x5e92a19f"); + }); + + it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABI, + functionName: "createAppWithIsolatedBilling", + args: [SALT, sampleRelease()], + }), + ).not.toThrow(); + expect(() => + encodeFunctionData({ + abi: AppControllerABI, + functionName: "upgradeApp", + args: [APP, sampleRelease()], + }), + ).not.toThrow(); + }); + + it("round-trips the containerPolicy field through the ABI", () => { + const policy: ContainerPolicy = { + args: ["--flag"], + cmdOverride: ["/bin/run"], + env: [{ key: "FOO", value: "bar" }], + envOverride: [], + restartPolicy: "always", + }; + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "createApp", + args: [SALT, sampleRelease(policy)], + }); + const { args } = decodeFunctionData({ abi: AppControllerABI, data }); + // args = [salt, release]; release.containerPolicy is the 4th tuple field + const release = args![1] as { containerPolicy: ContainerPolicy }; + expect(release.containerPolicy).toEqual(policy); + }); + + it("keeps the old 3-field selector out of the ABI (drift guard)", () => { + expect(() => + // The pre-v1.5.0 3-field Release shape must no longer encode against this ABI. + encodeFunctionData({ + abi: AppControllerABI, + functionName: "createApp", + args: [ + SALT, + { + rmsRelease: { artifacts: [], upgradeByTime: 0 }, + publicEnv: "0x" as Hex, + encryptedEnv: "0x" as Hex, + // containerPolicy intentionally omitted -> viem must reject the arity + }, + ], + }), + ).toThrow(); + }); +}); diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index e3ec18dd..c724caa3 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -248,6 +248,36 @@ export interface EnvironmentConfig { baseRPCURL?: string; } +export interface EnvVar { + key: string; + value: string; +} + +/** + * Container runtime policy attached to a release (AppController v1.5.0+). + * + * Added to the on-chain `Release` struct in eigenx-contracts (KMS-006). All + * fields are optional knobs over the container's entrypoint/runtime; an empty + * policy (see EMPTY_CONTAINER_POLICY) preserves the image's own + * CMD/ENTRYPOINT/env. + */ +export interface ContainerPolicy { + args: string[]; + cmdOverride: string[]; + env: EnvVar[]; + envOverride: EnvVar[]; + restartPolicy: string; +} + +/** An empty ContainerPolicy — defers entirely to the image defaults. */ +export const EMPTY_CONTAINER_POLICY: ContainerPolicy = { + args: [], + cmdOverride: [], + env: [], + envOverride: [], + restartPolicy: "", +}; + export interface Release { rmsRelease: { artifacts: Array<{ @@ -258,6 +288,10 @@ export interface Release { }; publicEnv: Uint8Array; // JSON bytes encryptedEnv: Uint8Array; // Encrypted string bytes + // Container runtime policy (AppController v1.5.0+ `Release.containerPolicy`). + // Optional in the SDK type for backwards-compatible construction; the encoder + // substitutes EMPTY_CONTAINER_POLICY when callers omit it. + containerPolicy?: ContainerPolicy; } export interface ParsedEnvironment { From b462d3a4047463467bd99b12d27f0f6699f62fdb Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 14:38:02 -0300 Subject: [PATCH 27/55] fix(sdk): select AppController Release ABI per environment (v1.4 vs v1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit swapped the vendored ABI wholesale to v1.5.x, which would have broken sepolia / mainnet-alpha — both still run AppController v1.4.0 (3-field Release, createApp selector 0xa60daa8f). Only sepolia-dev is on v1.5.x (4-field Release + containerPolicy, selector 0x5e92a19f). Verified on-chain via each controller's version() and createApp selector. Make the SDK support BOTH formats, selected per environment: - Keep the v1.5 ABI as AppController.json and re-add the v1.4 ABI as AppController.v1_4.json (the two also differ on getApps AppConfig shape, so the whole ABI is selected, not just the create/upgrade entries). - Add `releaseAbiVersion?: "v1.4" | "v1.5"` to EnvironmentConfig; set sepolia-dev = v1.5, sepolia + mainnet-alpha = v1.4. Omitted defaults to v1.5. - caller.ts: appControllerAbiFor(env) picks the ABI; releaseForViem(release, env) includes containerPolicy only on v1.5. All read/lifecycle calls also route through the version-aware ABI. - Drop the now-unused `Address` import in environment.ts (pre-existing lint error in a file this change touches). Tests: per-version encoding (0xa60daa8f vs 0x5e92a19f, containerPolicy round-trip, arity guards) + env→ABI selection through getEnvironmentConfig. 31 SDK tests pass; tsc + eslint clean on changed files. E2E verified on both: sepolia-dev (v1.5) and sepolia-prod (v1.4) each created an app on-chain (status STARTED) where the wholesale-swap build would have reverted on one of them. (Provisioning "Failed state" for the bare nginx test image is unrelated to the ABI.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/abis/AppController.v1_4.json | 1186 +++++++++++++++++ .../src/client/common/config/environment.ts | 4 +- .../sdk/src/client/common/contract/caller.ts | 106 +- .../common/contract/env-abi-selection.test.ts | 51 + .../common/contract/release-encoding.test.ts | 164 ++- packages/sdk/src/client/common/types/index.ts | 10 + 6 files changed, 1404 insertions(+), 117 deletions(-) create mode 100644 packages/sdk/src/client/common/abis/AppController.v1_4.json create mode 100644 packages/sdk/src/client/common/contract/env-abi-selection.test.ts diff --git a/packages/sdk/src/client/common/abis/AppController.v1_4.json b/packages/sdk/src/client/common/abis/AppController.v1_4.json new file mode 100644 index 00000000..4608a2ed --- /dev/null +++ b/packages/sdk/src/client/common/abis/AppController.v1_4.json @@ -0,0 +1,1186 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_version", + "type": "string", + "internalType": "string" + }, + { + "name": "_permissionController", + "type": "address", + "internalType": "contractIPermissionController" + }, + { + "name": "_releaseManager", + "type": "address", + "internalType": "contractIReleaseManager" + }, + { + "name": "_computeAVSRegistrar", + "type": "address", + "internalType": "contractIComputeAVSRegistrar" + }, + { + "name": "_computeOperator", + "type": "address", + "internalType": "contractIComputeOperator" + }, + { + "name": "_appBeacon", + "type": "address", + "internalType": "contractIBeacon" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "API_PERMISSION_TYPEHASH", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "appBeacon", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIBeacon" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "calculateApiPermissionDigestHash", + "inputs": [ + { + "name": "permission", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "expiry", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "calculateAppId", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "computeAVSRegistrar", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIComputeAVSRegistrar" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "computeOperator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIComputeOperator" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "createApp", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createAppWithIsolatedBilling", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getActiveAppCount", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBillingType", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppCreator", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppLatestReleaseBlockNumber", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppOperatorSetId", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppStatus", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getApps", + "inputs": [ + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByBillingAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByCreator", + "inputs": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByDeveloper", + "inputs": [ + { + "name": "developer", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMaxActiveAppsPerUser", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "globalActiveAppCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "maxGlobalActiveApps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "permissionController", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIPermissionController" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "releaseManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIReleaseManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setMaxActiveAppsPerUser", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "limit", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMaxGlobalActiveApps", + "inputs": [ + { + "name": "limit", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "startApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "stopApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "suspend", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateAppByAdmin", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateAppMetadataURI", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "metadataURI", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "AppCreated", + "inputs": [ + { + "name": "creator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "operatorSetId", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppMetadataURIUpdated", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "metadataURI", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppStarted", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppStopped", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppSuspended", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppTerminated", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppTerminatedByAdmin", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppUpgraded", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "rmsReleaseId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "release", + "type": "tuple", + "indexed": false, + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GlobalMaxActiveAppsSet", + "inputs": [ + { + "name": "limit", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MaxActiveAppsSet", + "inputs": [ + { + "name": "user", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "limit", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccountHasActiveApps", + "inputs": [] + }, + { + "type": "error", + "name": "AppAlreadyExists", + "inputs": [] + }, + { + "type": "error", + "name": "AppDoesNotExist", + "inputs": [] + }, + { + "type": "error", + "name": "GlobalMaxActiveAppsExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidAppStatus", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidPermissions", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReleaseMetadataURI", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidShortString", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSignature", + "inputs": [] + }, + { + "type": "error", + "name": "MaxActiveAppsExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "MoreThanOneArtifact", + "inputs": [] + }, + { + "type": "error", + "name": "SignatureExpired", + "inputs": [] + }, + { + "type": "error", + "name": "StringTooLong", + "inputs": [ + { + "name": "str", + "type": "string", + "internalType": "string" + } + ] + } +] diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index 0bb4d76d..a1009e63 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -2,7 +2,6 @@ * Environment configuration for different networks */ -import { Address } from "viem"; import { BillingEnvironmentConfig, EnvironmentConfig } from "../types"; // Chain IDs @@ -41,6 +40,7 @@ const ENVIRONMENTS: Record> = { name: "sepolia", build: "dev", appControllerAddress: "0xa86DC1C47cb2518327fB4f9A1627F51966c83B92", + releaseAbiVersion: "v1.5", // AppController upgraded to v1.5.x (containerPolicy) permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", @@ -54,6 +54,7 @@ const ENVIRONMENTS: Record> = { name: "sepolia", build: "prod", appControllerAddress: "0x0dd810a6ffba6a9820a10d97b659f07d8d23d4E2", + releaseAbiVersion: "v1.4", // prod still on AppController v1.4.0 (3-field Release) permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.15.203:8080", @@ -68,6 +69,7 @@ const ENVIRONMENTS: Record> = { name: "mainnet-alpha", build: "prod", appControllerAddress: "0xc38d35Fc995e75342A21CBd6D770305b142Fbe67", + releaseAbiVersion: "v1.4", // prod still on AppController v1.4.0 (3-field Release) permissionControllerAddress: ChainAddresses[MAINNET_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.2:8080", diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 1a004d67..8520f701 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -34,9 +34,31 @@ import { import { Release, ContainerPolicy, EMPTY_CONTAINER_POLICY } from "../types"; import { getChainFromID } from "../utils/helpers"; -import AppControllerABI from "../abis/AppController.json"; +// The on-chain AppController `Release` struct differs by contract version: +// v1.4.x (sepolia, mainnet-alpha): 3-field Release +// v1.5.x (sepolia-dev): 4-field Release (+ containerPolicy, KMS-006) +// The two ABIs also diverge on createApp/upgradeApp selectors and getApps +// AppConfig shape, so we select the whole ABI by environment version. +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; import PermissionControllerABI from "../abis/PermissionController.json"; +/** + * Select the AppController ABI matching the environment's deployed contract + * version. Defaults to the latest (v1.5) when a config omits the field, so + * new environments opt into older behavior explicitly. + */ +function appControllerAbiFor(environmentConfig: EnvironmentConfig) { + return environmentConfig.releaseAbiVersion === "v1.4" + ? AppControllerABIv1_4 + : AppControllerABIv1_5; +} + +/** Whether this environment's Release struct carries the v1.5+ containerPolicy field. */ +function supportsContainerPolicy(environmentConfig: EnvironmentConfig): boolean { + return environmentConfig.releaseAbiVersion !== "v1.4"; +} + /** * Build the viem-encodable `containerPolicy` tuple for the AppController * `Release` struct (v1.5.0+). Falls back to an empty policy when the caller @@ -52,6 +74,28 @@ function containerPolicyForViem(policy: ContainerPolicy = EMPTY_CONTAINER_POLICY }; } +/** + * Build the viem-encodable `release` tuple for createApp/upgradeApp, including + * the v1.5+ `containerPolicy` field only when the target contract expects it. + */ +function releaseForViem(release: Release, environmentConfig: EnvironmentConfig) { + const base = { + rmsRelease: { + artifacts: release.rmsRelease.artifacts.map((artifact) => ({ + digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, + registry: artifact.registry, + })), + upgradeByTime: release.rmsRelease.upgradeByTime, + }, + publicEnv: bytesToHex(release.publicEnv) as Hex, + encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + }; + if (!supportsContainerPolicy(environmentConfig)) { + return base; + } + return { ...base, containerPolicy: containerPolicyForViem(release.containerPolicy) }; +} + /** * Gas estimation result */ @@ -202,7 +246,7 @@ export async function calculateAppID(options: CalculateAppIDOptions): Promise ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - containerPolicy: containerPolicyForViem(release.containerPolicy), - }; + // Convert Release Uint8Array values to hex strings for viem (version-aware: + // includes containerPolicy only on v1.5+ contracts). + const release_ = releaseForViem(release, environmentConfig); const functionName = options.billTo === "app" ? "createAppWithIsolatedBilling" : "createApp"; const createData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName, - args: [saltHex, releaseForViem], + args: [saltHex, release_], }); // 3. Pack accept admin call @@ -754,25 +788,13 @@ export async function prepareUpgradeBatch( needsPermissionChange, } = options; - // 1. Pack upgrade app call - // Convert Release Uint8Array values to hex strings for viem - const releaseForViem = { - rmsRelease: { - artifacts: release.rmsRelease.artifacts.map((artifact) => ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - containerPolicy: containerPolicyForViem(release.containerPolicy), - }; + // 1. Pack upgrade app call (version-aware Release encoding). + const release_ = releaseForViem(release, environmentConfig); const upgradeData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "upgradeApp", - args: [appID, releaseForViem], + args: [appID, release_], }); // 2. Start with upgrade execution @@ -964,7 +986,7 @@ export async function sendAndWaitForTransaction( if (callError.data) { try { const decoded = decodeErrorResult({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), data: callError.data, }); const formattedError = formatAppControllerError(decoded); @@ -1034,7 +1056,7 @@ export async function getActiveAppCount( ): Promise { const count = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getActiveAppCount", args: [user], }); @@ -1052,7 +1074,7 @@ export async function getMaxActiveAppsPerUser( ): Promise { const quota = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getMaxActiveAppsPerUser", args: [user], }); @@ -1077,7 +1099,7 @@ export async function getAppsByCreator( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByCreator", args: [creator, offset, limit], })) as [Address[], AppConfig[]]; @@ -1101,7 +1123,7 @@ export async function getAppsByDeveloper( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByDeveloper", args: [developer, offset, limit], })) as [Address[], AppConfig[]]; @@ -1123,7 +1145,7 @@ export async function getBillingType( ): Promise { const result = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getBillingType", args: [app], }); @@ -1142,7 +1164,7 @@ export async function getAppsByBillingAccount( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByBillingAccount", args: [account, offset, limit], })) as [Address[], AppConfig[]]; @@ -1201,7 +1223,7 @@ export async function getAppLatestReleaseBlockNumbers( publicClient .readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppLatestReleaseBlockNumber", args: [appID], }) @@ -1270,7 +1292,7 @@ export async function suspend( const { walletClient, publicClient, environmentConfig, account, apps } = options; const suspendData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "suspend", args: [account, apps], }); diff --git a/packages/sdk/src/client/common/contract/env-abi-selection.test.ts b/packages/sdk/src/client/common/contract/env-abi-selection.test.ts new file mode 100644 index 00000000..1621d06f --- /dev/null +++ b/packages/sdk/src/client/common/contract/env-abi-selection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { encodeFunctionData, type Hex } from "viem"; +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; +import { getEnvironmentConfig } from "../config/environment"; +import { EMPTY_CONTAINER_POLICY } from "../types"; + +// Mirror caller.ts selection logic to assert config wires the right ABI per env. +function abiFor(v?: string) { + return v === "v1.4" ? AppControllerABIv1_4 : AppControllerABIv1_5; +} + +describe("env -> AppController ABI selection", () => { + const rms = { + artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "r" }], + upgradeByTime: 4_000_000_000, + }; + const origBuild = process.env.BUILD_TYPE; + const cases: Array<[string, string, string]> = [["sepolia-dev", "dev", "0x5e92a19f"]]; + for (const [env, build, sel] of cases) { + it(`${env} selects selector ${sel}`, () => { + process.env.BUILD_TYPE = build; + const cfg = getEnvironmentConfig(env); + const abi = abiFor(cfg.releaseAbiVersion); + const rel = + cfg.releaseAbiVersion === "v1.4" + ? { rmsRelease: rms, publicEnv: "0x" as Hex, encryptedEnv: "0x" as Hex } + : { + rmsRelease: rms, + publicEnv: "0x" as Hex, + encryptedEnv: "0x" as Hex, + containerPolicy: EMPTY_CONTAINER_POLICY, + }; + const data = encodeFunctionData({ + abi, + functionName: "createApp", + args: [`0x${"22".repeat(32)}` as Hex, rel], + }); + expect(data.slice(0, 10)).toBe(sel); + if (origBuild === undefined) delete process.env.BUILD_TYPE; + else process.env.BUILD_TYPE = origBuild; + }); + } + it("prod envs are pinned to v1.4", () => { + process.env.BUILD_TYPE = "prod"; + expect(getEnvironmentConfig("sepolia").releaseAbiVersion).toBe("v1.4"); + expect(getEnvironmentConfig("mainnet-alpha").releaseAbiVersion).toBe("v1.4"); + if (origBuild === undefined) delete process.env.BUILD_TYPE; + else process.env.BUILD_TYPE = origBuild; + }); +}); diff --git a/packages/sdk/src/client/common/contract/release-encoding.test.ts b/packages/sdk/src/client/common/contract/release-encoding.test.ts index 6d47b640..ce520d6f 100644 --- a/packages/sdk/src/client/common/contract/release-encoding.test.ts +++ b/packages/sdk/src/client/common/contract/release-encoding.test.ts @@ -1,93 +1,109 @@ import { describe, expect, it } from "vitest"; import { decodeFunctionData, encodeFunctionData, type Hex } from "viem"; -import AppControllerABI from "../abis/AppController.json"; +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; import { EMPTY_CONTAINER_POLICY, type ContainerPolicy } from "../types"; /** - * Regression guard for the AppController v1.5.x `Release` ABI. + * Regression guard for the per-environment AppController `Release` ABI. * - * v1.5.0 (eigenx-contracts KMS-006) added a 4th field `containerPolicy` to the - * on-chain `Release` struct, which changed the `createApp` selector from - * 0xa60daa8f to 0x5e92a19f. The SDK had shipped the 3-field ABI, so every - * deploy/upgrade encoded the old selector and reverted with empty data against - * the upgraded contract. These tests pin the new selectors and the 4-field - * encoding so the drift cannot silently return. + * v1.5.x (sepolia-dev) added a 4th field `containerPolicy` to the on-chain + * `Release` struct (eigenx-contracts KMS-006), changing the `createApp` + * selector from 0xa60daa8f to 0x5e92a19f. v1.4.x (sepolia, mainnet-alpha) is + * still on the 3-field struct. The SDK ships both ABIs and selects per + * environment, so both shapes must keep encoding to their respective selectors. */ -describe("AppController Release encoding (v1.5.x containerPolicy)", () => { - const sampleRelease = (containerPolicy?: ContainerPolicy) => ({ - rmsRelease: { - artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "docker.io/acme/app" }], - upgradeByTime: 4_000_000_000, - }, - publicEnv: "0x" as Hex, - encryptedEnv: "0x" as Hex, - containerPolicy: containerPolicy ?? EMPTY_CONTAINER_POLICY, - }); - +describe("AppController Release encoding (per-version)", () => { const SALT = `0x${"22".repeat(32)}` as Hex; const APP = `0x${"33".repeat(20)}` as Hex; + const rmsRelease = { + artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "docker.io/acme/app" }], + upgradeByTime: 4_000_000_000, + }; + const release3 = { rmsRelease, publicEnv: "0x" as Hex, encryptedEnv: "0x" as Hex }; + const release4 = { ...release3, containerPolicy: EMPTY_CONTAINER_POLICY }; - it("encodes createApp with the v1.5.x selector (4-field Release)", () => { - const data = encodeFunctionData({ - abi: AppControllerABI, - functionName: "createApp", - args: [SALT, sampleRelease()], + describe("v1.5 (4-field, sepolia-dev)", () => { + it("encodes createApp with the v1.5 selector", () => { + const data = encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, release4], + }); + expect(data.slice(0, 10)).toBe("0x5e92a19f"); }); - expect(data.slice(0, 10)).toBe("0x5e92a19f"); - }); - it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { - expect(() => - encodeFunctionData({ - abi: AppControllerABI, - functionName: "createAppWithIsolatedBilling", - args: [SALT, sampleRelease()], - }), - ).not.toThrow(); - expect(() => - encodeFunctionData({ - abi: AppControllerABI, - functionName: "upgradeApp", - args: [APP, sampleRelease()], - }), - ).not.toThrow(); - }); + it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createAppWithIsolatedBilling", + args: [SALT, release4], + }), + ).not.toThrow(); + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "upgradeApp", + args: [APP, release4], + }), + ).not.toThrow(); + }); - it("round-trips the containerPolicy field through the ABI", () => { - const policy: ContainerPolicy = { - args: ["--flag"], - cmdOverride: ["/bin/run"], - env: [{ key: "FOO", value: "bar" }], - envOverride: [], - restartPolicy: "always", - }; - const data = encodeFunctionData({ - abi: AppControllerABI, - functionName: "createApp", - args: [SALT, sampleRelease(policy)], + it("round-trips the containerPolicy field", () => { + const policy: ContainerPolicy = { + args: ["--flag"], + cmdOverride: ["/bin/run"], + env: [{ key: "FOO", value: "bar" }], + envOverride: [], + restartPolicy: "always", + }; + const data = encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, { ...release3, containerPolicy: policy }], + }); + const { args } = decodeFunctionData({ abi: AppControllerABIv1_5, data }); + const release = args![1] as { containerPolicy: ContainerPolicy }; + expect(release.containerPolicy).toEqual(policy); + }); + + it("rejects the 3-field shape (arity guard)", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, release3], + }), + ).toThrow(); }); - const { args } = decodeFunctionData({ abi: AppControllerABI, data }); - // args = [salt, release]; release.containerPolicy is the 4th tuple field - const release = args![1] as { containerPolicy: ContainerPolicy }; - expect(release.containerPolicy).toEqual(policy); }); - it("keeps the old 3-field selector out of the ABI (drift guard)", () => { - expect(() => - // The pre-v1.5.0 3-field Release shape must no longer encode against this ABI. - encodeFunctionData({ - abi: AppControllerABI, + describe("v1.4 (3-field, sepolia / mainnet-alpha)", () => { + it("encodes createApp with the legacy selector", () => { + const data = encodeFunctionData({ + abi: AppControllerABIv1_4, functionName: "createApp", - args: [ - SALT, - { - rmsRelease: { artifacts: [], upgradeByTime: 0 }, - publicEnv: "0x" as Hex, - encryptedEnv: "0x" as Hex, - // containerPolicy intentionally omitted -> viem must reject the arity - }, - ], - }), - ).toThrow(); + args: [SALT, release3], + }); + expect(data.slice(0, 10)).toBe("0xa60daa8f"); + }); + + it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_4, + functionName: "createAppWithIsolatedBilling", + args: [SALT, release3], + }), + ).not.toThrow(); + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_4, + functionName: "upgradeApp", + args: [APP, release3], + }), + ).not.toThrow(); + }); }); }); diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c724caa3..0106305d 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -232,11 +232,21 @@ export interface BillingEnvironmentConfig { billingApiServerURL: string; } +/** + * On-chain AppController ABI version for an environment. + * - "v1.4": 3-field Release struct (sepolia, mainnet-alpha) + * - "v1.5": 4-field Release struct with containerPolicy (sepolia-dev) + * Omitted defaults to the latest ("v1.5") in the contract caller. + */ +export type AppControllerAbiVersion = "v1.4" | "v1.5"; + export interface EnvironmentConfig { name: string; build: "dev" | "prod"; chainID: bigint; appControllerAddress: Address; + /** Deployed AppController ABI version; selects Release encoding. Defaults to v1.5 when omitted. */ + releaseAbiVersion?: AppControllerAbiVersion; permissionControllerAddress: string; erc7702DelegatorAddress: string; kmsServerURL: string; From 383536137a475c52f36ec012e198bcb0eb0265a5 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 16:16:54 -0300 Subject: [PATCH 28/55] fix: dedupe shared watch-timeout helpers after folding RND-568 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding the RND-568 (upgrade watch) and RND-569 (deploy watch) branches both introduced the same watcher primitives — WATCH_DEFAULT_TIMEOUT_SECONDS, WatchTimeoutError, resolveWatchTimeoutSeconds — and a duplicate WatchTimeoutError re-export in client/index.ts. The 3-way merge kept both copies (different line positions), which would not compile. Keep the single shared definition near the top of watcher.ts (the superset: includes the heartbeat interval and optional message) and the single export on line 53; remove the RND-568 duplicates. Both watchUntilRunning and watchUntilUpgradeComplete now use the one shared WatchTimeoutError/resolveWatchTimeoutSeconds. SDK tsc clean; sdk+cli build; 23 sdk + 61 cli tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk/src/client/common/contract/watcher.ts | 53 +------------------ packages/sdk/src/client/index.ts | 3 -- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/packages/sdk/src/client/common/contract/watcher.ts b/packages/sdk/src/client/common/contract/watcher.ts index 5ae66c89..71cf2c2f 100644 --- a/packages/sdk/src/client/common/contract/watcher.ts +++ b/packages/sdk/src/client/common/contract/watcher.ts @@ -214,57 +214,8 @@ export interface WatchUntilUpgradeCompleteOptions { const APP_STATUS_STOPPED = "Stopped"; -/** Default upgrade watch timeout: 10 minutes. */ -export const WATCH_DEFAULT_TIMEOUT_SECONDS = 10 * 60; - -/** - * Error thrown when {@link watchUntilUpgradeComplete} exceeds its deadline - * without observing a terminal status (Stopped or Running) for the app. - * - * Callers (e.g. the CLI) can catch this and surface a recovery hint pointing - * the user at `ecloud compute app info `. - */ -export class WatchTimeoutError extends Error { - public readonly appId: Address; - public readonly lastStatus: string | undefined; - public readonly elapsedSeconds: number; - public readonly timeoutSeconds: number; - - constructor(params: { - appId: Address; - lastStatus: string | undefined; - elapsedSeconds: number; - timeoutSeconds: number; - }) { - super( - `Timed out after ${params.elapsedSeconds}s waiting for upgrade to complete (last status: ${ - params.lastStatus ?? "unknown" - })`, - ); - this.name = "WatchTimeoutError"; - this.appId = params.appId; - this.lastStatus = params.lastStatus; - this.elapsedSeconds = params.elapsedSeconds; - this.timeoutSeconds = params.timeoutSeconds; - } -} - -/** - * Resolve the upgrade watch timeout from explicit option, env var, or default. - */ -function resolveWatchTimeoutSeconds(explicit?: number): number { - if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) { - return explicit; - } - const fromEnv = process.env.ECLOUD_WATCH_TIMEOUT_SECONDS; - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; - } - } - return WATCH_DEFAULT_TIMEOUT_SECONDS; -} +// WATCH_DEFAULT_TIMEOUT_SECONDS, WatchTimeoutError, and resolveWatchTimeoutSeconds +// are shared with watchUntilRunning and declared once near the top of this file. /** * Watch app until upgrade completes diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index e475e399..eb5eadef 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -115,9 +115,6 @@ export { type EstimateGasOptions, } from "./common/contract/caller"; -// Export watcher errors so callers can react to typed terminal failures. -export { WatchTimeoutError } from "./common/contract/watcher"; - // Export batch gas estimation and delegation check export { estimateBatchGas, From d19d74a6cefb8d808340b652ba125e817aa990bf Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 10:09:03 -0300 Subject: [PATCH 29/55] fix(cli): default optional prompts in non-interactive deploy/upgrade (RND-564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-interactive `compute app deploy` / `upgrade` (CI, scripted use, agents) previously failed one flag at a time: each run errored "Cannot prompt in non-interactive mode" for the next unset optional flag, so callers had to add a flag, retry, hit the next prompt, and repeat. `--force` only short-circuited the verifiable-build confirmation, contradicting its "skip all prompts" help. Each optional prompt that has a safe default now falls back to it (with a single warning line) when there is no TTY, instead of throwing: - --dockerfile/--image-ref: build from the discovered ./Dockerfile - --env-file: no env file (".env" auto-detect unchanged) - --log-visibility: private (never silently public) - --resource-usage-monitoring: disable Required inputs with no safe default (--instance-type, image source, app name/id) still error via ensureInteractive — but now as a single error after the defaultable prompts resolve, not buried mid-cascade. Also fixes the ticket's symptom #3: when --image-ref is provided without --dockerfile, deploy/upgrade now skip the Dockerfile prompt entirely so a stray Dockerfile in the working directory no longer hijacks an existing-image deploy (interactively a spurious "build or deploy existing?" prompt; non-interactively a silent flip to a local build). Scope: narrow per RND-564/RND-571. The broader RND-589 consolidation (--instance-type/--environment defaults, --non-interactive+CI detection, all-at-once required-flag errors, env bindings, version-check hook) is intentionally out of scope here. Tested: 12 new prompts.test.ts cases (73 pass); manual non-interactive deploy on sepolia-dev runs through every prompt to the on-chain step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 18 ++- .../cli/src/commands/compute/app/upgrade.ts | 18 ++- .../cli/src/utils/__tests__/prompts.test.ts | 135 +++++++++++++++++- packages/cli/src/utils/prompts.ts | 53 +++++++ 4 files changed, 215 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 202b17b1..8d20e059 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -72,7 +72,8 @@ export default class AppDeploy extends Command { }), "log-visibility": Flags.string({ required: false, - description: "Log visibility setting: public, private, or off", + description: + "Log visibility setting: public, private, or off (non-interactive default: private)", options: ["public", "private", "off"], env: "ECLOUD_LOG_VISIBILITY", }), @@ -88,7 +89,8 @@ export default class AppDeploy extends Command { }), "resource-usage-monitoring": Flags.string({ required: false, - description: "Resource usage monitoring: enable or disable", + description: + "Resource usage monitoring: enable or disable (non-interactive default: disable)", options: ["enable", "disable"], env: "ECLOUD_RESOURCE_USAGE_MONITORING", }), @@ -359,9 +361,17 @@ export default class AppDeploy extends Command { } } - // 1. Get dockerfile path interactively (skip when using verifiable image) + // 1. Get dockerfile path interactively (skip when using verifiable image). + // Also skip when --image-ref is explicitly provided and no --dockerfile was: + // the user is deploying an existing image, so a stray Dockerfile in the + // working directory must not trigger a "build or deploy existing?" prompt + // (or, in non-interactive mode, silently flip the deploy to a local build). const isVerifiable = verifiableMode !== "none"; - const dockerfilePath = isVerifiable ? "" : await getDockerfileInteractive(flags.dockerfile); + const deployExistingImageRef = !!flags["image-ref"] && !flags.dockerfile; + const dockerfilePath = + isVerifiable || deployExistingImageRef + ? "" + : await getDockerfileInteractive(flags.dockerfile); const buildFromDockerfile = dockerfilePath !== ""; // 2. Get image reference interactively (context-aware) diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 90a3ad04..3fd9e081 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -76,7 +76,8 @@ export default class AppUpgrade extends Command { }), "log-visibility": Flags.string({ required: false, - description: "Log visibility setting: public, private, or off", + description: + "Log visibility setting: public, private, or off (non-interactive default: private)", options: ["public", "private", "off"], env: "ECLOUD_LOG_VISIBILITY", }), @@ -87,7 +88,8 @@ export default class AppUpgrade extends Command { }), "resource-usage-monitoring": Flags.string({ required: false, - description: "Resource usage monitoring: enable or disable", + description: + "Resource usage monitoring: enable or disable (non-interactive default: disable)", options: ["enable", "disable"], env: "ECLOUD_RESOURCE_USAGE_MONITORING", }), @@ -290,9 +292,17 @@ export default class AppUpgrade extends Command { } } - // 2. Get dockerfile path interactively (skip when using verifiable image) + // 2. Get dockerfile path interactively (skip when using verifiable image). + // Also skip when --image-ref is explicitly provided and no --dockerfile was: + // the user is upgrading to an existing image, so a stray Dockerfile in the + // working directory must not trigger a "build or deploy existing?" prompt + // (or, in non-interactive mode, silently flip the upgrade to a local build). const isVerifiable = verifiableMode !== "none"; - const dockerfilePath = isVerifiable ? "" : await getDockerfileInteractive(flags.dockerfile); + const deployExistingImageRef = !!flags["image-ref"] && !flags.dockerfile; + const dockerfilePath = + isVerifiable || deployExistingImageRef + ? "" + : await getDockerfileInteractive(flags.dockerfile); const buildFromDockerfile = dockerfilePath !== ""; // 3. Get image reference interactively (context-aware) diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index ecb3ee0c..7fec2259 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getEnvironmentInteractive, promptUseVerifiableBuild } from "../prompts"; +import fs from "fs"; +import { + getEnvironmentInteractive, + promptUseVerifiableBuild, + getDockerfileInteractive, + getEnvFileInteractive, + getLogSettingsInteractive, + getResourceUsageMonitoringInteractive, + getInstanceTypeInteractive, +} from "../prompts"; /** * Regression tests for two non-interactive mode bugs introduced in PR #126: @@ -84,3 +93,127 @@ describe("prompts non-interactive regressions", () => { }); }); }); + +/** + * RND-564 / RND-571: in non-interactive (non-TTY) mode, the optional deploy / + * upgrade prompts must fall back to a safe default with a warning instead of + * throwing "Cannot prompt in non-interactive mode". Required inputs that have + * no safe default (e.g. --instance-type) must still error. + */ +describe("non-interactive flag defaulting (RND-564)", () => { + const origIsTTY = process.stdin.isTTY; + + afterEach(() => { + process.stdin.isTTY = origIsTTY; + vi.restoreAllMocks(); + }); + + describe("getEnvFileInteractive", () => { + it("defaults to no env file in non-TTY mode when none is found", async () => { + process.stdin.isTTY = false; + // No explicit path, and no auto-detected .env on disk. + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getEnvFileInteractive(undefined)).resolves.toBe(""); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); + }); + + it("still returns an explicitly provided, existing env file in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockImplementation((p) => p === "custom.env"); + await expect(getEnvFileInteractive("custom.env")).resolves.toBe("custom.env"); + }); + }); + + describe("getLogSettingsInteractive", () => { + it("defaults to private logs in non-TTY mode", async () => { + process.stdin.isTTY = false; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getLogSettingsInteractive(undefined)).resolves.toEqual({ + logRedirect: "always", + publicLogs: false, + }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--log-visibility.*private/)); + }); + + it("never silently defaults to public in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const settings = await getLogSettingsInteractive(undefined); + expect(settings.publicLogs).toBe(false); + }); + + it("honors an explicit --log-visibility value regardless of TTY", async () => { + process.stdin.isTTY = false; + await expect(getLogSettingsInteractive("public")).resolves.toEqual({ + logRedirect: "always", + publicLogs: true, + }); + }); + }); + + describe("getResourceUsageMonitoringInteractive", () => { + it("defaults to disable in non-TTY mode", async () => { + process.stdin.isTTY = false; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getResourceUsageMonitoringInteractive(undefined)).resolves.toBe("disable"); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/--resource-usage-monitoring.*disable/), + ); + }); + + it("honors an explicit value regardless of TTY", async () => { + process.stdin.isTTY = false; + await expect(getResourceUsageMonitoringInteractive("enable")).resolves.toBe("enable"); + }); + }); + + describe("getDockerfileInteractive", () => { + it("returns '' (deploy existing image) when no Dockerfile exists, even in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockReturnValue(false); + await expect(getDockerfileInteractive(undefined)).resolves.toBe(""); + }); + + it("defaults to building the discovered Dockerfile in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockReturnValue(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await getDockerfileInteractive(undefined); + expect(result).toMatch(/Dockerfile$/); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); + }); + + it("returns an explicitly provided Dockerfile path verbatim", async () => { + process.stdin.isTTY = false; + await expect(getDockerfileInteractive("./custom/Dockerfile")).resolves.toBe( + "./custom/Dockerfile", + ); + }); + }); + + describe("getInstanceTypeInteractive", () => { + const types = [ + { sku: "g1-standard-2s", friendly_name: "Standard 2s", description: "2 vCPU, SEV-SNP" }, + { sku: "g1-standard-4t", friendly_name: "Standard 4t", description: "4 vCPU, TDX" }, + ]; + + it("still errors in non-TTY mode when no instance type is provided (no safe default)", async () => { + process.stdin.isTTY = false; + await expect(getInstanceTypeInteractive(undefined, "", types)).rejects.toThrow( + /Cannot prompt in non-interactive mode.*--instance-type/, + ); + }); + + it("returns an explicitly provided, valid instance type in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(getInstanceTypeInteractive("g1-standard-2s", "", types)).resolves.toBe( + "g1-standard-2s", + ); + }); + }); +}); diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 1c16c7cc..e1017893 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -62,6 +62,28 @@ function ensureInteractive(missingFlagHint: string): void { } } +/** + * True when there is no attached TTY (CI, scripted use, agents). + * + * The optional deploy/upgrade prompts (env file, log visibility, resource-usage + * monitoring, Dockerfile-vs-existing-image) all have a safe default, so rather + * than throwing via `ensureInteractive` they fall back to that default and log + * what was chosen. Required inputs with no safe default (image source, + * instance type, app name/id) keep calling `ensureInteractive` and still error. + */ +function isNonInteractive(): boolean { + return !process.stdin.isTTY; +} + +/** + * Emit a single, consistent line noting that an optional flag was defaulted + * because we are running without a TTY. Keeps the choice visible/auditable in + * CI logs. + */ +function warnDefaulted(flagHint: string, chosen: string): void { + console.warn(`Warning: ${flagHint} not set in non-interactive mode; defaulting to ${chosen}.`); +} + // ==================== Dockerfile Selection ==================== /** @@ -83,6 +105,17 @@ export async function getDockerfileInteractive(dockerfilePath?: string): Promise return ""; } + // In non-interactive mode we cannot ask the user to choose between building + // the discovered Dockerfile and deploying an existing image. Default to + // building from the discovered Dockerfile — the intent when a Dockerfile is + // sitting in the working directory. Callers that pass --image-ref skip this + // helper entirely (see deploy.ts / upgrade.ts), so this default never + // overrides an explicit image reference. + if (isNonInteractive()) { + warnDefaulted("--dockerfile/--image-ref", `build from '${dockerfilePath_resolved}'`); + return dockerfilePath_resolved; + } + // Interactive prompt when Dockerfile exists ensureInteractive("--dockerfile or --image-ref"); console.log(`\nFound Dockerfile in ${cwd}`); @@ -820,6 +853,13 @@ export async function getEnvFileInteractive(envFilePath?: string): Promise Date: Wed, 3 Jun 2026 16:49:36 -0300 Subject: [PATCH 30/55] feat(cli): non-interactive detection + all-at-once missing-input helper (RND-589) isNonInteractive now keys off --non-interactive flag, CI=true, then !isTTY. Add collectMissingRequiredInputs to report every missing required deploy/upgrade input in one error instead of cascading one prompt at a time. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/utils/__tests__/prompts.test.ts | 57 ++++++++++++++++++ packages/cli/src/utils/prompts.ts | 58 +++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index 7fec2259..efc71df2 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -8,6 +8,8 @@ import { getLogSettingsInteractive, getResourceUsageMonitoringInteractive, getInstanceTypeInteractive, + isNonInteractive, + collectMissingRequiredInputs, } from "../prompts"; /** @@ -217,3 +219,58 @@ describe("non-interactive flag defaulting (RND-564)", () => { }); }); }); + +describe("isNonInteractive (RND-589 detection)", () => { + const origTTY = process.stdin.isTTY; + const origCI = process.env.CI; + afterEach(() => { + process.stdin.isTTY = origTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + }); + + it("true when --non-interactive flag is set, even on a TTY", () => { + process.stdin.isTTY = true; + delete process.env.CI; + expect(isNonInteractive({ "non-interactive": true })).toBe(true); + }); + it("true when CI=true, even on a TTY", () => { + process.stdin.isTTY = true; + process.env.CI = "true"; + expect(isNonInteractive()).toBe(true); + }); + it("true when no TTY", () => { + process.stdin.isTTY = false; + delete process.env.CI; + expect(isNonInteractive()).toBe(true); + }); + it("false on a TTY with no CI and no flag", () => { + process.stdin.isTTY = true; + delete process.env.CI; + expect(isNonInteractive()).toBe(false); + }); +}); + +describe("collectMissingRequiredInputs (RND-589 all-at-once)", () => { + it("returns [] when image source + name present", () => { + expect( + collectMissingRequiredInputs({ imageRef: "r", name: "n" }, "name"), + ).toEqual([]); + }); + it("lists both missing image source and name", () => { + const m = collectMissingRequiredInputs({ verifiable: false }, "name"); + expect(m.join(" ")).toMatch(/image source/); + expect(m.join(" ")).toMatch(/--name/); + }); + it("accepts verifiable git source as image source", () => { + const m = collectMissingRequiredInputs( + { verifiable: true, repo: "x", commit: "y", name: "n" }, + "name", + ); + expect(m).toEqual([]); + }); + it("reports only the image source for upgrade (app-id handled at call site)", () => { + const m = collectMissingRequiredInputs({}, "app-id"); + expect(m).toEqual([expect.stringMatching(/image source/)]); + }); +}); diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index e1017893..70cbdf6d 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -63,15 +63,21 @@ function ensureInteractive(missingFlagHint: string): void { } /** - * True when there is no attached TTY (CI, scripted use, agents). + * True when the CLI should run without interactive prompts. + * + * Precedence: an explicit `--non-interactive` flag, then `CI=true`, then the + * absence of a TTY. `isTTY` alone is unreliable in pipes/CI, so the flag and CI + * env give callers (agents, scripts) a deterministic signal. * * The optional deploy/upgrade prompts (env file, log visibility, resource-usage - * monitoring, Dockerfile-vs-existing-image) all have a safe default, so rather - * than throwing via `ensureInteractive` they fall back to that default and log - * what was chosen. Required inputs with no safe default (image source, - * instance type, app name/id) keep calling `ensureInteractive` and still error. + * monitoring, Dockerfile-vs-existing-image, instance type) all have a safe + * default, so rather than throwing via `ensureInteractive` they fall back to + * that default and log what was chosen. Required inputs with no safe default + * (image source, app name/id) are reported all-at-once by the command layer. */ -function isNonInteractive(): boolean { +export function isNonInteractive(flags?: { "non-interactive"?: boolean }): boolean { + if (flags?.["non-interactive"]) return true; + if (process.env.CI === "true") return true; return !process.stdin.isTTY; } @@ -84,6 +90,46 @@ function warnDefaulted(flagHint: string, chosen: string): void { console.warn(`Warning: ${flagHint} not set in non-interactive mode; defaulting to ${chosen}.`); } +/** The set of required deploy/upgrade inputs that have no safe default. */ +export interface RequiredInputState { + imageRef?: string; + dockerfile?: string; + verifiable?: boolean; + repo?: string; + commit?: string; + name?: string; +} + +/** + * Collect ALL missing required deploy/upgrade inputs for non-interactive mode, + * so the caller can report them in one error instead of one prompt at a time. + * + * Covers the image source (and, for deploy, `--name`). The upgrade `app-id` is + * a positional arg, so its presence is checked at the call site; this helper + * only reports the image-source half for `"app-id"` callers. + * + * @param identityFlag "name" for deploy, "app-id" for upgrade. + */ +export function collectMissingRequiredInputs( + state: RequiredInputState, + identityFlag: "name" | "app-id", +): string[] { + const missing: string[] = []; + const hasImageSource = + !!state.imageRef || + !!state.dockerfile || + (!!state.verifiable && !!state.repo && !!state.commit); + if (!hasImageSource) { + missing.push( + "an image source (one of: --image-ref, --dockerfile, or --verifiable with --repo and --commit)", + ); + } + if (identityFlag === "name" && !state.name) { + missing.push("--name"); + } + return missing; +} + // ==================== Dockerfile Selection ==================== /** From 02f6260eea4c610605fbb360b3fab936990149a3 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 16:53:27 -0300 Subject: [PATCH 31/55] feat(cli): --non-interactive flag, instance-type default, mainnet-alpha prod default (RND-589) - Add --non-interactive common flag (env ECLOUD_NON_INTERACTIVE). - getInstanceTypeInteractive: in non-interactive mode default to g1-standard-2s (deploy) or reuse the pinned defaultSKU (upgrade) instead of erroring. - Default --environment to mainnet-alpha on prod builds (was sepolia). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/flags.ts | 10 +++++- .../cli/src/utils/__tests__/prompts.test.ts | 34 ++++++++++++++++--- packages/cli/src/utils/prompts.ts | 27 +++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index a64e7680..3b297d56 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -12,6 +12,7 @@ export type CommonFlags = { "max-fee-per-gas"?: string; "max-priority-fee"?: string; nonce?: string; + "non-interactive"?: boolean; }; export const commonFlags = { @@ -20,7 +21,7 @@ export const commonFlags = { description: "Deployment environment to use", env: "ECLOUD_ENV", default: async () => - getDefaultEnvironment() || (getBuildType() === "dev" ? "sepolia-dev" : "sepolia"), + getDefaultEnvironment() || (getBuildType() === "dev" ? "sepolia-dev" : "mainnet-alpha"), }), "private-key": Flags.string({ required: false, @@ -51,6 +52,13 @@ export const commonFlags = { required: false, description: 'Override transaction nonce (integer or "latest" to replace a stuck transaction)', }), + "non-interactive": Flags.boolean({ + required: false, + description: + "Assume non-interactive mode: default safe prompts and error all-at-once on missing required inputs", + env: "ECLOUD_NON_INTERACTIVE", + default: false, + }), }; /** diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index efc71df2..1f55333d 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -204,18 +204,42 @@ describe("non-interactive flag defaulting (RND-564)", () => { { sku: "g1-standard-4t", friendly_name: "Standard 4t", description: "4 vCPU, TDX" }, ]; - it("still errors in non-TTY mode when no instance type is provided (no safe default)", async () => { + it("returns an explicitly provided, valid instance type in non-TTY mode", async () => { process.stdin.isTTY = false; - await expect(getInstanceTypeInteractive(undefined, "", types)).rejects.toThrow( - /Cannot prompt in non-interactive mode.*--instance-type/, + await expect(getInstanceTypeInteractive("g1-standard-2s", "", types)).resolves.toBe( + "g1-standard-2s", ); }); - it("returns an explicitly provided, valid instance type in non-TTY mode", async () => { + // RND-589: deploy with no instance type defaults to g1-standard-2s in non-interactive mode. + it("defaults to g1-standard-2s in non-interactive deploy when available", async () => { process.stdin.isTTY = false; - await expect(getInstanceTypeInteractive("g1-standard-2s", "", types)).resolves.toBe( + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getInstanceTypeInteractive(undefined, "", types, true)).resolves.toBe( "g1-standard-2s", ); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--instance-type.*g1-standard-2s/)); + }); + + // RND-589: upgrade reuses the currently pinned type (defaultSKU) instead of prompting. + it("reuses defaultSKU (pinned type) in non-interactive upgrade", async () => { + process.stdin.isTTY = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect( + getInstanceTypeInteractive(undefined, "g1-standard-4t", types, true), + ).resolves.toBe("g1-standard-4t"); + }); + + it("errors in non-interactive when the default SKU is not offered", async () => { + process.stdin.isTTY = false; + await expect( + getInstanceTypeInteractive( + undefined, + "", + [{ sku: "g1-micro-1v", friendly_name: "m", description: "" }], + true, + ), + ).rejects.toThrow(/instance-type/); }); }); }); diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 70cbdf6d..40449f18 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -938,6 +938,13 @@ export async function getEnvFileInteractive(envFilePath?: string): Promise { if (instanceType) { // Validate provided instance type @@ -993,6 +1001,25 @@ export async function getInstanceTypeInteractive( throw new Error(`Invalid instance-type: ${instanceType} (must be one of: ${validSKUs})`); } + // Non-interactive: pick a default instead of prompting (RND-589). + // Callers pass isNonInteractive(flags) (flag + CI + isTTY) as this argument. + if (nonInteractive) { + // Upgrade path: reuse the currently pinned type when one is known. + if (defaultSKU) { + warnDefaulted("--instance-type", `current type '${defaultSKU}'`); + return defaultSKU; + } + // Deploy path: smallest TEE tier, if the backend offers it. + if (availableTypes.some((t) => t.sku === DEFAULT_NONINTERACTIVE_SKU)) { + warnDefaulted("--instance-type", `'${DEFAULT_NONINTERACTIVE_SKU}'`); + return DEFAULT_NONINTERACTIVE_SKU; + } + const validSKUs = availableTypes.map((t) => t.sku).join(", "); + throw new Error( + `Cannot pick a default --instance-type in non-interactive mode: '${DEFAULT_NONINTERACTIVE_SKU}' not offered (available: ${validSKUs}). Provide --instance-type.`, + ); + } + ensureInteractive("--instance-type"); const isCurrentType = defaultSKU !== ""; From 80f1c51e3ea71029b17c2a1152773d1bc4fbbcdd Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:08:54 -0300 Subject: [PATCH 32/55] feat(cli): wire non-interactive deploy precheck + instance-type default + ECLOUD_FORCE (RND-589) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 8d20e059..ebf4bab4 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -22,6 +22,8 @@ import { promptVerifiableGitSourceInputs, promptVerifiablePrebuiltImageRef, imagePathToBlob, + isNonInteractive, + collectMissingRequiredInputs, } from "../../../utils/prompts"; import { invalidateProfileCache, setLinkedAppForDirectory } from "../../../utils/globalConfig"; import { getClientId } from "../../../utils/version"; @@ -148,12 +150,36 @@ export default class AppDeploy extends Command { force: Flags.boolean({ description: "Skip all confirmation prompts", default: false, + env: "ECLOUD_FORCE", }), }; async run() { return withTelemetry(this, async () => { const { flags } = await this.parse(AppDeploy); + + // Non-interactive: report every missing required input at once instead of + // failing one prompt at a time (RND-589). + if (isNonInteractive(flags)) { + const missing = collectMissingRequiredInputs( + { + imageRef: flags["image-ref"], + dockerfile: flags.dockerfile, + verifiable: flags.verifiable, + repo: flags.repo, + commit: flags.commit, + name: flags.name, + }, + "name", + ); + if (missing.length > 0) { + this.error( + `Missing required input(s) for non-interactive deploy:\n - ${missing.join("\n - ")}`, + { exit: 1 }, + ); + } + } + const compute = await createComputeClient(flags); // Get validated values from flags (mutated by createComputeClient) @@ -406,8 +432,9 @@ export default class AppDeploy extends Command { ); const instanceType = await getInstanceTypeInteractive( flags["instance-type"], - "", // No default for new deployments + "", // No pinned default for new deployments; non-interactive falls back to g1-standard-2s availableTypes, + isNonInteractive(flags), ); // 6. Get log visibility interactively From e518d04f4a567a7d6611023a2d7810b9b80c0140 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:11:48 -0300 Subject: [PATCH 33/55] feat(cli): wire non-interactive upgrade precheck + pinned-type reuse + ECLOUD_APP_ID/ECLOUD_FORCE (RND-589) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/upgrade.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 3fd9e081..3741e0c0 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -20,6 +20,8 @@ import { promptVerifiableSourceType, promptVerifiableGitSourceInputs, promptVerifiablePrebuiltImageRef, + isNonInteractive, + collectMissingRequiredInputs, } from "../../../utils/prompts"; import { getClientId } from "../../../utils/version"; import { setLinkedAppForDirectory, invalidateProfileCache } from "../../../utils/globalConfig"; @@ -40,7 +42,7 @@ export default class AppUpgrade extends Command { static args = { "app-id": Args.string({ - description: "App ID or name to upgrade", + description: "App ID or name to upgrade (env: ECLOUD_APP_ID)", required: false, }), }; @@ -131,12 +133,42 @@ export default class AppUpgrade extends Command { force: Flags.boolean({ description: "Skip all confirmation prompts", default: false, + env: "ECLOUD_FORCE", }), }; async run() { return withTelemetry(this, async () => { const { args, flags } = await this.parse(AppUpgrade); + + // Resolve the app to upgrade from the positional arg or ECLOUD_APP_ID env + // (oclif Args don't support env bindings directly). + const appIdInput = args["app-id"] ?? process.env.ECLOUD_APP_ID; + + // Non-interactive: report every missing required input at once instead of + // failing one prompt at a time (RND-589). + if (isNonInteractive(flags)) { + const missing = collectMissingRequiredInputs( + { + imageRef: flags["image-ref"], + dockerfile: flags.dockerfile, + verifiable: flags.verifiable, + repo: flags.repo, + commit: flags.commit, + }, + "app-id", + ); + if (!appIdInput) { + missing.push("app-id (positional arg or ECLOUD_APP_ID)"); + } + if (missing.length > 0) { + this.error( + `Missing required input(s) for non-interactive upgrade:\n - ${missing.join("\n - ")}`, + { exit: 1 }, + ); + } + } + const compute = await createComputeClient(flags); // Get validated values from flags (mutated by createComputeClient) @@ -147,7 +179,7 @@ export default class AppUpgrade extends Command { // 1. Get app ID interactively if not provided const appID = await getOrPromptAppID({ - appID: args["app-id"], + appID: appIdInput, environment, privateKey, rpcUrl, @@ -348,6 +380,7 @@ export default class AppUpgrade extends Command { flags["instance-type"], currentInstanceType, availableTypes, + isNonInteractive(flags), ); // 7. Get log visibility interactively From 39e20d13f782ca0c1bf4c1ab7f4c4fc409305225 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:12:43 -0300 Subject: [PATCH 34/55] fix(cli): version-check hook no-ops in non-interactive mode (RND-589) The update prompt ran before any command, with no TTY guard, so in CI/agents it threw and read as the command failing. Skip it (and snooze the check) when CI=true or no TTY. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/hooks/init/version-check.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/src/hooks/init/version-check.ts b/packages/cli/src/hooks/init/version-check.ts index e6426cca..ca052a37 100644 --- a/packages/cli/src/hooks/init/version-check.ts +++ b/packages/cli/src/hooks/init/version-check.ts @@ -110,6 +110,15 @@ const hook: Hook<"init"> = async function (options) { ), ); + // Non-interactive (CI / no TTY): never block the command on an update prompt. + // The hook runs before the command parses flags, so it cannot see + // --non-interactive; CI and isTTY cover the agent/CI failure mode (RND-589). + if (process.env.CI === "true" || !process.stdin.isTTY) { + globalConfig.last_version_check = now; + saveGlobalConfig(globalConfig); + return; + } + const shouldUpdate = await confirm({ message: "Would you like to update now?", default: true, From 787a6fc9738ef7167f4e961cd50d9054344375aa Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:16:04 -0300 Subject: [PATCH 35/55] test(cli): version-check hook simulates TTY; cover non-interactive no-op (RND-589) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../init/__tests__/version-check.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/hooks/init/__tests__/version-check.test.ts b/packages/cli/src/hooks/init/__tests__/version-check.test.ts index 303ed587..9e382ff8 100644 --- a/packages/cli/src/hooks/init/__tests__/version-check.test.ts +++ b/packages/cli/src/hooks/init/__tests__/version-check.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; vi.mock("@inquirer/prompts", () => ({ confirm: vi.fn(), @@ -45,12 +45,24 @@ function mockFetch(distTags: Record | null, ok = true) { } describe("version-check init hook", () => { + const origTTY = process.stdin.isTTY; + const origCI = process.env.CI; + beforeEach(() => { vi.clearAllMocks(); (getCliVersion as Mock).mockReturnValue("1.0.0"); (getBuildType as Mock).mockReturnValue("prod"); (loadGlobalConfig as Mock).mockReturnValue({}); (saveGlobalConfig as Mock).mockImplementation(() => {}); + // Default to an interactive terminal so the update-prompt path runs. + process.stdin.isTTY = true; + delete process.env.CI; + }); + + afterEach(() => { + process.stdin.isTTY = origTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; }); it("skips check for upgrade command", async () => { @@ -98,6 +110,30 @@ describe("version-check init hook", () => { expect(upgradePackage).not.toHaveBeenCalled(); }); + // RND-589: in non-interactive mode the hook must not block on the update + // prompt (it ran before the command and read as a failure in CI/agents). + it("does not prompt in non-interactive mode (no TTY)", async () => { + process.stdin.isTTY = false; + mockFetch({ latest: "2.0.0" }); + const ctx = createMockContext(); + + await hook.call(ctx as any, { id: "auth:login" } as any); + + expect(confirm).not.toHaveBeenCalled(); + expect(upgradePackage).not.toHaveBeenCalled(); + }); + + it("does not prompt when CI=true even on a TTY", async () => { + process.stdin.isTTY = true; + process.env.CI = "true"; + mockFetch({ latest: "2.0.0" }); + const ctx = createMockContext(); + + await hook.call(ctx as any, { id: "auth:login" } as any); + + expect(confirm).not.toHaveBeenCalled(); + }); + it("upgrades when user confirms", async () => { mockFetch({ latest: "2.0.0" }); (confirm as Mock).mockResolvedValue(true); From ca9a16d4c39d964b77339719224d5e6e0fa5e04d Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:17:33 -0300 Subject: [PATCH 36/55] style(cli): prettier formatting for RND-589 changes --- packages/cli/src/flags.ts | 4 +++- packages/cli/src/hooks/init/version-check.ts | 7 ++++++- packages/cli/src/utils/__tests__/prompts.test.ts | 4 +--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index 3b297d56..a32fb8c2 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -110,7 +110,9 @@ export async function applyTxOverrides( } else { const parsed = Number(nonceStr); if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`Invalid nonce: "${nonceStr}". Must be a non-negative integer or "latest".`); + throw new Error( + `Invalid nonce: "${nonceStr}". Must be a non-negative integer or "latest".`, + ); } nonce = parsed; } diff --git a/packages/cli/src/hooks/init/version-check.ts b/packages/cli/src/hooks/init/version-check.ts index ca052a37..4ae0d374 100644 --- a/packages/cli/src/hooks/init/version-check.ts +++ b/packages/cli/src/hooks/init/version-check.ts @@ -12,7 +12,12 @@ const NPM_REGISTRY_URL = "https://registry.npmjs.org/@layr-labs/ecloud-cli"; const VERSION_CHECK_TIMEOUT_MS = 3000; const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; -function parseVersion(v: string): { major: number; minor: number; patch: number; prerelease: string | null } { +function parseVersion(v: string): { + major: number; + minor: number; + patch: number; + prerelease: string | null; +} { const clean = v.replace(/^v/, ""); const [core, ...rest] = clean.split("-"); const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index 1f55333d..26c36930 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -277,9 +277,7 @@ describe("isNonInteractive (RND-589 detection)", () => { describe("collectMissingRequiredInputs (RND-589 all-at-once)", () => { it("returns [] when image source + name present", () => { - expect( - collectMissingRequiredInputs({ imageRef: "r", name: "n" }, "name"), - ).toEqual([]); + expect(collectMissingRequiredInputs({ imageRef: "r", name: "n" }, "name")).toEqual([]); }); it("lists both missing image source and name", () => { const m = collectMissingRequiredInputs({ verifiable: false }, "name"); From 0e2fae6e21e0d223afe5f0aef4d1e04f8a26bc97 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:27:36 -0300 Subject: [PATCH 37/55] test(cli): lock in mainnet-alpha prod / sepolia-dev dev env default (RND-589) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/__tests__/flags.test.ts | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/cli/src/__tests__/flags.test.ts diff --git a/packages/cli/src/__tests__/flags.test.ts b/packages/cli/src/__tests__/flags.test.ts new file mode 100644 index 00000000..a9a56ec6 --- /dev/null +++ b/packages/cli/src/__tests__/flags.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock the two inputs the environment default depends on. +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getBuildType: vi.fn(), +})); +vi.mock("../utils/globalConfig", () => ({ + getDefaultEnvironment: vi.fn(), +})); + +import { commonFlags } from "../flags"; +import { getBuildType } from "@layr-labs/ecloud-sdk"; +import { getDefaultEnvironment } from "../utils/globalConfig"; + +/** + * RND-589: prod builds default --environment to mainnet-alpha (was sepolia); + * dev builds stay sepolia-dev; an explicit configured default always wins. + * The default is an async thunk on the oclif flag definition. + */ +describe("commonFlags.environment default (RND-589)", () => { + // oclif stores the default function on the flag's `default` property. + const resolveDefault = () => + (commonFlags.environment as unknown as { default: () => Promise }).default(); + + afterEach(() => vi.clearAllMocks()); + + it("defaults to mainnet-alpha on prod builds with no configured default", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue(undefined); + (getBuildType as ReturnType).mockReturnValue("prod"); + await expect(resolveDefault()).resolves.toBe("mainnet-alpha"); + }); + + it("defaults to sepolia-dev on dev builds", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue(undefined); + (getBuildType as ReturnType).mockReturnValue("dev"); + await expect(resolveDefault()).resolves.toBe("sepolia-dev"); + }); + + it("honors a configured default over the build-type fallback", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue("sepolia"); + (getBuildType as ReturnType).mockReturnValue("prod"); + await expect(resolveDefault()).resolves.toBe("sepolia"); + }); +}); From 7a564758aa15b22182e1384e208e852d1cc1aa37 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 17:53:10 -0300 Subject: [PATCH 38/55] feat(sdk): retry 502/503/504 in requestWithRetry, not just 429 (RND-592) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/utils/__tests__/retry.test.ts | 51 +++++++++++++++++++ packages/sdk/src/client/common/utils/retry.ts | 9 +++- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/client/common/utils/__tests__/retry.test.ts diff --git a/packages/sdk/src/client/common/utils/__tests__/retry.test.ts b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts new file mode 100644 index 00000000..4677d130 --- /dev/null +++ b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock axios so requestWithRetry's HTTP calls are controllable. +const axiosMock = vi.fn(); +vi.mock("axios", () => ({ + default: (config: unknown) => axiosMock(config), +})); + +import { requestWithRetry } from "../retry"; + +/** + * RND-592: requestWithRetry retries 429 (rate limit) AND transient gateway + * errors 502/503/504, then returns the last response. 2xx/4xx (other than 429) + * are returned immediately without retry. + */ +describe("requestWithRetry retryable statuses (RND-592)", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("returns immediately on 200 (no retry)", async () => { + axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: "ok" }); + const res = await requestWithRetry({ url: "x" }); + expect(res.status).toBe(200); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry a 404", async () => { + axiosMock.mockResolvedValueOnce({ status: 404, headers: {}, data: "nope" }); + const res = await requestWithRetry({ url: "x" }); + expect(res.status).toBe(404); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + for (const status of [429, 502, 503, 504]) { + it(`retries ${status} then succeeds`, async () => { + vi.useFakeTimers(); + axiosMock + .mockResolvedValueOnce({ status, headers: { "retry-after": "0" }, data: "" }) + .mockResolvedValueOnce({ status: 200, headers: {}, data: "ok" }); + + const promise = requestWithRetry({ url: "x" }); + await vi.runAllTimersAsync(); + const res = await promise; + + expect(res.status).toBe(200); + expect(axiosMock).toHaveBeenCalledTimes(2); + }); + } +}); diff --git a/packages/sdk/src/client/common/utils/retry.ts b/packages/sdk/src/client/common/utils/retry.ts index 6bd0419e..1b07ea3f 100644 --- a/packages/sdk/src/client/common/utils/retry.ts +++ b/packages/sdk/src/client/common/utils/retry.ts @@ -4,6 +4,13 @@ const MAX_RETRIES = 5; const INITIAL_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 30000; +/** + * HTTP statuses worth retrying with backoff: rate limiting (429) and transient + * gateway/availability errors (502/503/504). Agents that poll status hit these + * under load, and they are routinely recoverable on retry (RND-592). + */ +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -27,7 +34,7 @@ export async function requestWithRetry(config: AxiosRequestConfig): Promise true }); lastResponse = res; - if (res.status !== 429) { + if (!RETRYABLE_STATUSES.has(res.status)) { return res; } From 41bcc654a55adf7445f255ea432350b1e442b3a1 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 18:10:59 -0300 Subject: [PATCH 39/55] feat(cli): add 'compute app status [--wait] [--json]' (RND-592) One-shot status via getStatuses; --json emits { appId, status }. --wait blocks via the bounded watchDeployment machinery (honors --watch-timeout / ECLOUD_WATCH_TIMEOUT_SECONDS), catches WatchTimeoutError with a recovery hint, then prints a final status read. Gives agents a supported wait mechanism instead of tight-looping 'app info'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compute/app/__tests__/status.test.ts | 78 +++++++++++ .../cli/src/commands/compute/app/status.ts | 122 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 packages/cli/src/commands/compute/app/__tests__/status.test.ts create mode 100644 packages/cli/src/commands/compute/app/status.ts diff --git a/packages/cli/src/commands/compute/app/__tests__/status.test.ts b/packages/cli/src/commands/compute/app/__tests__/status.test.ts new file mode 100644 index 00000000..6f60f22c --- /dev/null +++ b/packages/cli/src/commands/compute/app/__tests__/status.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const APP = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09"; + +const getStatuses = vi.fn(); +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getEnvironmentConfig: vi.fn(() => ({ defaultRPCURL: "https://rpc.test" })), + UserApiClient: class { + getStatuses = getStatuses; + }, + WatchTimeoutError: class WatchTimeoutError extends Error {}, +})); +vi.mock("../../../../flags", () => ({ + commonFlags: {}, + validateCommonFlags: vi.fn(async (f: Record) => ({ + ...f, + environment: "sepolia-dev", + "private-key": "0xkey", + })), +})); +vi.mock("../../../../utils/prompts", () => ({ + getOrPromptAppID: vi.fn(async () => APP), +})); +vi.mock("../../../../utils/version", () => ({ getClientId: vi.fn(() => "test") })); +vi.mock("../../../../utils/viemClients", () => ({ + createViemClients: vi.fn(() => ({ publicClient: {}, walletClient: {} })), +})); +const watchDeployment = vi.fn(); +vi.mock("../../../../client", () => ({ + createComputeClient: vi.fn(async () => ({ app: { watchDeployment } })), +})); + +describe("compute app status (RND-592)", () => { + let logOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + logOutput = []; + }); + + async function runCommand(flags: Record) { + const { default: AppStatus } = await import("../status"); + const cmd = new AppStatus([], {} as any); + cmd.parse = vi.fn().mockResolvedValue({ args: { "app-id": APP }, flags }); + cmd.log = vi.fn((...a: string[]) => logOutput.push(a.join(" "))); + cmd.warn = vi.fn() as any; + await cmd.run(); + return logOutput; + } + + it("prints the one-shot status from getStatuses", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); + const out = await runCommand({ wait: false, json: false }); + expect(getStatuses).toHaveBeenCalledWith([APP]); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(out.join("\n")).toContain("Running"); + }); + + it("emits machine-readable JSON with --json", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Terminated" }]); + const out = await runCommand({ wait: false, json: true }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Terminated" }); + }); + + it("falls back to Unknown when the API returns nothing", async () => { + getStatuses.mockResolvedValue([]); + const out = await runCommand({ wait: false, json: true }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Unknown" }); + }); + + it("--wait blocks via watchDeployment, then does a final status read", async () => { + watchDeployment.mockResolvedValue(undefined); + getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); + const out = await runCommand({ wait: true, json: true, "watch-timeout": 30 }); + expect(watchDeployment).toHaveBeenCalledWith(APP, { timeoutSeconds: 30 }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Running" }); + }); +}); diff --git a/packages/cli/src/commands/compute/app/status.ts b/packages/cli/src/commands/compute/app/status.ts new file mode 100644 index 00000000..97bcb8c4 --- /dev/null +++ b/packages/cli/src/commands/compute/app/status.ts @@ -0,0 +1,122 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, UserApiClient, WatchTimeoutError } from "@layr-labs/ecloud-sdk"; +import { commonFlags, validateCommonFlags } from "../../../flags"; +import { getOrPromptAppID } from "../../../utils/prompts"; +import { getClientId } from "../../../utils/version"; +import { createComputeClient } from "../../../client"; +import { createViemClients } from "../../../utils/viemClients"; +import chalk from "chalk"; + +export default class AppStatus extends Command { + static description = + "Show an app's current status. Use --wait to block until it settles instead of polling `app info` in a loop."; + + static examples = [ + "<%= config.bin %> compute app status 0xabc...", + "<%= config.bin %> compute app status 0xabc... --json", + "<%= config.bin %> compute app status 0xabc... --wait", + "ECLOUD_WATCH_TIMEOUT_SECONDS=120 <%= config.bin %> compute app status 0xabc... --wait", + ]; + + static args = { + "app-id": Args.string({ + description: "App ID or name (env: ECLOUD_APP_ID)", + required: false, + }), + }; + + static flags = { + ...commonFlags, + wait: Flags.boolean({ + description: + "Block until the app reaches a terminal status (Running/Stopped) or the watch timeout elapses, instead of returning immediately", + default: false, + }), + "watch-timeout": Flags.integer({ + description: + "With --wait: maximum seconds to wait before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", + }), + json: Flags.boolean({ + description: "Output machine-readable JSON ({ appId, status })", + default: false, + }), + }; + + async run() { + const { args, flags } = await this.parse(AppStatus); + + const validatedFlags = await validateCommonFlags(flags); + const environment = validatedFlags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = validatedFlags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = validatedFlags["private-key"]!; + + const appID = await getOrPromptAppID({ + appID: args["app-id"] ?? process.env.ECLOUD_APP_ID, + environment, + privateKey, + rpcUrl, + action: "check status of", + }); + + const { publicClient, walletClient } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + + if (flags.wait) { + // Reuse the bounded watch machinery (RND-568/569): polls server-side at a + // fixed cadence with 429/5xx backoff, throws WatchTimeoutError on timeout. + const compute = await createComputeClient(validatedFlags); + try { + await compute.app.watchDeployment(appID, { + timeoutSeconds: flags["watch-timeout"], + }); + } catch (err) { + if (err instanceof WatchTimeoutError) { + // Fall through to a final one-shot read + hint below rather than crash. + if (!flags.json) { + this.warn( + `Timed out after ${err.elapsedSeconds}s waiting for ${appID}. ` + + `Check 'ecloud compute app info ${appID}' or the orchestrator logs.`, + ); + } + } else { + throw err; + } + } + } + + // One-shot status read (also the final read after --wait). + const statuses = await userApiClient.getStatuses([appID]); + const status = statuses[0]?.status || "Unknown"; + + if (flags.json) { + this.log(JSON.stringify({ appId: appID, status })); + return; + } + + this.log(`${chalk.bold(appID)}: ${formatStatus(status)}`); + } +} + +function formatStatus(status: string): string { + switch (status.toLowerCase()) { + case "running": + case "started": + return chalk.green(status); + case "failed": + case "terminated": + return chalk.red(status); + case "stopped": + case "suspended": + return chalk.yellow(status); + default: + return status; + } +} From cbb050453afcd97b54f4841ae2c29f402e594200 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 18:13:51 -0300 Subject: [PATCH 40/55] docs(cli): agent skill uses 'app status --wait' instead of polling 'app info' (RND-592) Rewrite Gate 3 to use the bounded 'app status --wait' instead of a bare 'app info' poll loop (the tight loop that trips rate limits). Add --json to the supported-flags list and update the upgrade gate. Also brings SKILL.md into prettier compliance (was pre-existing non-conforming; whitespace-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/skills/deploy/SKILL.md | 133 ++++++++++++++++++---------- 1 file changed, 87 insertions(+), 46 deletions(-) diff --git a/packages/cli/skills/deploy/SKILL.md b/packages/cli/skills/deploy/SKILL.md index cb15f52b..4cc0e149 100644 --- a/packages/cli/skills/deploy/SKILL.md +++ b/packages/cli/skills/deploy/SKILL.md @@ -28,7 +28,7 @@ You are deploying to EigenCloud TEE (Trusted Execution Environment) infrastructu 5. **Derived keys (EVM + Solana wallets) are deterministic per app ID.** They persist across stop/start/upgrade. They are permanently lost if you terminate and redeploy — new app ID means new keys. -6. **`--json` is only available on:** `app releases`, `build submit`, `build status`, `build info`, `build list`, `build verify`. It is NOT available on: `app list`, `app info`, `app deploy`, `billing status`. Do not pass `--json` to commands that don't support it. +6. **`--json` is only available on:** `app status`, `app releases`, `build submit`, `build status`, `build info`, `build list`, `build verify`. It is NOT available on: `app list`, `app info`, `app deploy`, `billing status`. Do not pass `--json` to commands that don't support it. 7. **No `--dry-run` exists** anywhere in the CLI. No `--yes` on deploy/upgrade (they don't prompt interactively). `--force` only exists on: `app terminate`, `billing cancel`, `auth logout`. @@ -51,6 +51,7 @@ Run all five checks. Fix any that fail before proceeding. ```bash ecloud --version ``` + Pass: outputs `@layr-labs/ecloud-cli/`. Fail: `npm install -g @layr-labs/ecloud-cli` @@ -59,8 +60,10 @@ Fail: `npm install -g @layr-labs/ecloud-cli` ```bash ecloud auth whoami ``` + Pass: output contains `Address: 0x` and `Source: stored credentials`. Fail: + - New user → `ecloud auth generate --store` (generates key, stores in OS keyring) - Has existing key → `ecloud auth login` (interactive prompt for private key) - CI/automation → set `ECLOUD_PRIVATE_KEY` env var instead of keyring @@ -70,7 +73,9 @@ Fail: ```bash ecloud compute environment show ``` + Two environments exist: + - `sepolia` — **Testnet (default).** Uses Sepolia ETH for gas. Deploy here first to test. Billing subscription flow is the same but uses test payment methods. All on-chain records are on Sepolia. Dashboard: `verify-sepolia.eigencloud.xyz`. - `mainnet-alpha` — **Production.** Uses real ETH for gas, real USDC for payments. Apps are publicly visible on the mainnet verify dashboard: `verify.eigencloud.xyz`. Use when the app is ready for real users and real funds. @@ -84,8 +89,10 @@ Fix: `ecloud compute env set sepolia --yes` (or `mainnet-alpha`). ```bash ecloud billing status ``` + Pass: output contains `Status: ✓ Active`. Fail: + 1. `ecloud billing subscribe` — creates the subscription 2. `ecloud billing top-up --amount 50` — purchases USDC credits @@ -94,6 +101,7 @@ Fail: ```bash docker version --format '{{.Client.Version}}' ``` + Pass: outputs a version string. Fail: install Docker Desktop or Docker Engine. @@ -108,23 +116,29 @@ uname -m ``` **If `arm64` (Apple Silicon — most common dev machine):** + ```bash docker buildx build --platform linux/amd64 -t /: --push . ``` + This cross-compiles AND pushes in one step. The `--push` flag is required because buildx cross-compiled images can't be loaded into the local daemon. **If `x86_64` (native):** + ```bash docker build -t /: . && docker push /: ``` **After push, verify architecture:** + ```bash docker manifest inspect /: 2>&1 | grep architecture ``` + Must contain `amd64`. If it shows `arm64`, the image will crash in the TEE. **Image requirements:** + - Must be in a **public** registry (Docker Hub, GHCR, etc.) — the TEE pulls at deploy time - Must be OCI-compliant - App should bind to `0.0.0.0` on its listening port (not `127.0.0.1`) @@ -135,14 +149,14 @@ Must contain `amd64`. If it shows `arm64`, the image will crash in the TEE. ### Instance types -| Instance Type | TEE | Approx $/hr | -|--------------|-----|-------------| -| `g1-micro-1v` | vTPM | ~$0.03 | -| `g1-standard-2s` | — | ~$0.04 | -| `g1-standard-2t` | Intel TDX | ~$0.07 | -| `g1-standard-4s` | — | ~$0.12 | -| `g1-standard-4t` | Intel TDX | ~$0.33 | -| `g1-standard-8t` | Intel TDX | ~$0.66 | +| Instance Type | TEE | Approx $/hr | +| ---------------- | --------- | ----------- | +| `g1-micro-1v` | vTPM | ~$0.03 | +| `g1-standard-2s` | — | ~$0.04 | +| `g1-standard-2t` | Intel TDX | ~$0.07 | +| `g1-standard-4s` | — | ~$0.12 | +| `g1-standard-4t` | Intel TDX | ~$0.33 | +| `g1-standard-8t` | Intel TDX | ~$0.66 | Suffix convention (inferred, not officially documented): `t` = Intel TDX, `s` = likely AMD SEV, `v` = vTPM. Only TDX is confirmed in official EigenCloud docs. Prices are approximate — sourced from billing line items and may change. @@ -192,12 +206,15 @@ ecloud compute build submit \ This blocks and streams build logs by default. Use `--no-follow` to exit immediately after submission. If `--no-follow` was used, poll for completion: + ```bash ecloud compute build status --json ``` + On build failure: `ecloud compute build logs --tail 50` Then deploy with the verifiable flag: + ```bash ecloud compute app deploy \ --name \ @@ -211,42 +228,58 @@ ecloud compute app deploy \ ``` Additional flags for complex builds: + - Multi-stage with dependencies: `--build-dependencies sha256:...` - TLS via Caddy: `--build-caddyfile Caddyfile` ### Deploy output parsing -| stdout/stderr contains | Meaning | Next step | -|------------------------|---------|-----------| -| `0x` + 40 hex chars + dashboard URL | Success — deploy initiated | Save app ID → Gate 3 | -| `subscription not active` | No billing subscription | `ecloud billing subscribe` → retry | -| `insufficient credits` | USDC balance too low | `ecloud billing top-up --amount ` → retry | -| Image pull error | Image not public, wrong arch, or bad ref | Verify with `docker manifest inspect` → retry | -| `already exists` | App name collision | Change `--name` or use `upgrade` on existing app | +| stdout/stderr contains | Meaning | Next step | +| ----------------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `0x` + 40 hex chars + dashboard URL | Success — deploy initiated | Save app ID → Gate 3 | +| `subscription not active` | No billing subscription | `ecloud billing subscribe` → retry | +| `insufficient credits` | USDC balance too low | `ecloud billing top-up --amount ` → retry | +| Image pull error | Image not public, wrong arch, or bad ref | Verify with `docker manifest inspect` → retry | +| `already exists` | App name collision | Change `--name` or use `upgrade` on existing app | --- ## Gate 3: Verify deployment -Deploy returns before the app is fully running. You must poll. +Deploy returns before the app is fully running. Wait for it to settle. -### Poll for running status +### Wait for running status + +**Use `app status --wait` — do NOT tight-loop `app info`.** A bare +`ecloud compute app info ` is a one-shot fetch with no pacing; calling +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`. ```bash -ecloud compute app info +# Blocks until the app reaches a terminal status or the timeout elapses. +ecloud compute app status --wait + +# Bound the wait (default 600s) and/or get machine-readable output: +ECLOUD_WATCH_TIMEOUT_SECONDS=180 ecloud compute app status --wait +ecloud compute app status --json # one-shot: {"appId","status"} ``` -Parse these fields from output: +`--json` emits `{"appId":"0x...","status":"Running"}`. Status is one of: +`Running`, `Deploying`, `Stopped`, `Terminated`, `Failed`, `Unknown`. + +- If `status --wait` returns `Running` → proceed to health check. +- If it times out (prints a recovery hint) → check `ecloud compute app info ` + once for details and `ecloud compute app logs `. +- If `Failed` → check logs: `ecloud compute app logs ` + +Use `app info ` (one-shot, not in a loop) only when you need richer +details — IP, EVM/Solana addresses, release info: -- **`Status:`** — one of: `Running`, `Deploying`, `Stopped`, `Terminated`, `Error` - **`IP:`** — public IP address (direct, no load balancer) - **`EVM Address:`** — TEE-derived EVM wallet (deterministic per app ID) - **`Solana Address:`** — TEE-derived Solana wallet (deterministic per app ID) -If `Deploying` → wait 15 seconds, poll again. Typical time to `Running`: 1-3 minutes. -If `Running` → proceed to health check. -If `Error` → check logs: `ecloud compute app logs ` - ### Health check The app's listening port is exposed directly on the public IP. Check whichever port your app binds to (templates default to 3000). @@ -255,11 +288,11 @@ The app's listening port is exposed directly on the public IP. Check whichever p curl -s -o /dev/null -w "%{http_code}" http://:/ ``` -| Response | Meaning | -|----------|---------| -| `200` | App is healthy and serving | -| `404` | App is running but has no route at `/` — check your app's routes | -| `000` | Connection refused — app not ready yet, wrong port, or app crashed on startup | +| Response | Meaning | +| -------- | ----------------------------------------------------------------------------- | +| `200` | App is healthy and serving | +| `404` | App is running but has no route at `/` — check your app's routes | +| `000` | Connection refused — app not ready yet, wrong port, or app crashed on startup | ### Verify provenance (verifiable builds only) @@ -289,6 +322,7 @@ ecloud compute app profile set \ **Constraints:** Profile name cannot contain spaces. Use hyphens or camelCase (e.g., `CitiBike-MPP` not `Citi Bike MPP`). Dashboard URLs: + - Sepolia: `https://verify-sepolia.eigencloud.xyz/app/` - Mainnet: `https://verify.eigencloud.xyz/app/` @@ -305,6 +339,7 @@ ecloud compute app upgrade \ ``` The repo must be public. After upgrade, verify provenance: + ```bash ecloud compute app releases --full ecloud compute build verify --json @@ -322,6 +357,7 @@ ecloud compute app upgrade \ ``` For verifiable upgrade: + ```bash ecloud compute app upgrade \ --verifiable \ @@ -330,7 +366,7 @@ ecloud compute app upgrade \ --verbose ``` -After upgrade, re-run Gate 3 (poll `app info` until `Running`, then health check). +After upgrade, re-run Gate 3 (`app status --wait` until `Running`, then health check). App ID and derived keys persist across upgrades. @@ -341,12 +377,15 @@ App ID and derived keys persist across upgrades. No native rollback command exists. Manual procedure: **1. Find previous release:** + ```bash ecloud compute app releases --json ``` + The `releases` array is ordered by creation time. Previous release = second-to-last entry. Extract `registryUrl` and `imageDigest`. **2. Upgrade to previous image:** + ```bash ecloud compute app upgrade \ --image-ref @ \ @@ -356,10 +395,12 @@ ecloud compute app upgrade \ **3. Verify:** re-run Gate 3. **If upgrade fails (app stuck or crashed):** + ```bash ecloud compute app terminate --force ecloud compute app deploy --name --image-ref --instance-type --verbose ``` + This creates a new app ID. Derived keys will be different. Only do this as a last resort. --- @@ -381,21 +422,21 @@ ecloud compute app info --address-count 5 # show more derived addresse ## Error recovery -| Error | Cause | Fix | -|-------|-------|-----| -| `App name 'X' not found` | Name lookup is profile-based, unreliable | Use hex app ID (0x...) | -| `subscription not active` | No billing subscription | `ecloud billing subscribe` | -| `insufficient credits` | USDC balance depleted | `ecloud billing top-up --amount ` | -| Image pull failure | Image not public or wrong architecture | `docker manifest inspect ` — verify `linux/amd64` | -| App crashes immediately after deploy | arm64 image deployed to TDX instance | Rebuild with `docker buildx build --platform linux/amd64` | -| `No builds found` on verify | Image was pushed directly, not via verifiable build | Redeploy with `--verifiable --repo --commit ` | -| Status stuck on `Deploying` | Large image or infrastructure delay | `ecloud compute app logs ` for diagnostics | -| Auth errors | Key not in keyring or expired | `ecloud auth whoami` to diagnose → `ecloud auth login` to fix | -| Wrong environment | Deployed to sepolia instead of mainnet (or vice versa) | `ecloud compute env show` → `ecloud compute env set --yes` | -| Logs return 425 error | Normal during provisioning (1-2+ min after deploy) | Wait and retry, or use `--watch` to stream when available | -| App shows "(unnamed)" on dashboard | `--name` only sets CLI name, not dashboard profile | `ecloud compute app profile set --name "Name"` | -| Profile name rejected | Spaces not allowed in profile names | Use hyphens or camelCase | -| Mainnet app unreachable despite "Running" status | Mainnet networking can take 5+ min after deploy (longer than sepolia) | Keep polling — sepolia ~30s, mainnet can be several minutes | +| Error | Cause | Fix | +| ------------------------------------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `App name 'X' not found` | Name lookup is profile-based, unreliable | Use hex app ID (0x...) | +| `subscription not active` | No billing subscription | `ecloud billing subscribe` | +| `insufficient credits` | USDC balance depleted | `ecloud billing top-up --amount ` | +| Image pull failure | Image not public or wrong architecture | `docker manifest inspect ` — verify `linux/amd64` | +| App crashes immediately after deploy | arm64 image deployed to TDX instance | Rebuild with `docker buildx build --platform linux/amd64` | +| `No builds found` on verify | Image was pushed directly, not via verifiable build | Redeploy with `--verifiable --repo --commit ` | +| Status stuck on `Deploying` | Large image or infrastructure delay | `ecloud compute app logs ` for diagnostics | +| Auth errors | Key not in keyring or expired | `ecloud auth whoami` to diagnose → `ecloud auth login` to fix | +| Wrong environment | Deployed to sepolia instead of mainnet (or vice versa) | `ecloud compute env show` → `ecloud compute env set --yes` | +| Logs return 425 error | Normal during provisioning (1-2+ min after deploy) | Wait and retry, or use `--watch` to stream when available | +| App shows "(unnamed)" on dashboard | `--name` only sets CLI name, not dashboard profile | `ecloud compute app profile set --name "Name"` | +| Profile name rejected | Spaces not allowed in profile names | Use hyphens or camelCase | +| Mainnet app unreachable despite "Running" status | Mainnet networking can take 5+ min after deploy (longer than sepolia) | Keep polling — sepolia ~30s, mainnet can be several minutes | --- From b22ef9a2400ef86a8d88789e3b120ab7d56b6126 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 18:20:43 -0300 Subject: [PATCH 41/55] test(cli): align promptUseVerifiableBuild tests with merged non-TTY confirm behavior The PR #162 merge changed confirmWithDefault to return the default in non-TTY mode instead of throwing, so promptUseVerifiableBuild(false) now resolves to false (regular build) rather than erroring. Update the two tests that asserted the old throw behavior. Mainnet deploy confirm stays safe: it's gated on !flags.force and defaults to false, so non-TTY without --force cancels. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/utils/__tests__/prompts.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index 26c36930..4fd2767f 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -80,18 +80,18 @@ describe("prompts non-interactive regressions", () => { await expect(promptUseVerifiableBuild(true)).resolves.toBe(false); }); - it("throws the 'Use --force' guidance when force is false in non-TTY mode", async () => { + // Post RND-568/569 merge: confirmWithDefault returns the default in non-TTY + // mode instead of throwing, so a verifiable-build confirm resolves to false + // (regular build) rather than erroring. RND-589-aligned: optional confirms + // never block a non-interactive run. + it("resolves to false (regular build) when force is false in non-TTY mode", async () => { process.stdin.isTTY = false; - await expect(promptUseVerifiableBuild(false)).rejects.toThrow( - /Cannot confirm "Build from verifiable source\?" in non-interactive mode\. Use --force/, - ); + await expect(promptUseVerifiableBuild(false)).resolves.toBe(false); }); - it("defaults force to false so existing callers still see the non-interactive error", async () => { + it("defaults force to false and still resolves to false in non-TTY mode", async () => { process.stdin.isTTY = false; - await expect(promptUseVerifiableBuild()).rejects.toThrow( - /Cannot confirm "Build from verifiable source\?" in non-interactive mode/, - ); + await expect(promptUseVerifiableBuild()).resolves.toBe(false); }); }); }); From 2ca26777bc8faad0794ecb83f90a0dc29663916c Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 18:38:48 -0300 Subject: [PATCH 42/55] fix(cli): confirmWithDefault returns the default in non-TTY instead of throwing (RND-589) Optional yes/no confirms must not block a non-interactive run. In non-TTY mode confirmWithDefault now returns its default rather than throwing 'Use --force'. Safe for the mainnet deploy confirm: that path is gated on !flags.force and defaults to false, so a non-interactive mainnet deploy without --force cancels rather than auto-proceeding. Makes the committed tests (which assert this) match the code. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/utils/prompts.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 40449f18..6fb6be02 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -1595,9 +1595,7 @@ export async function confirmWithDefault( defaultValue: boolean = false, ): Promise { if (!process.stdin.isTTY) { - throw new Error( - `Cannot confirm "${prompt}" in non-interactive mode. Use --force to skip confirmation prompts.`, - ); + return defaultValue; } return await inquirerConfirm({ message: prompt, From 9df2a11208aaa44fc7ad98849a18bc9984378b73 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 19:04:16 -0300 Subject: [PATCH 43/55] feat(cli): distinct exit codes for deploy/upgrade failure stages (RND-591) Callers keying off exit status can now tell which stage failed: 2 = invalid/missing input (pre-build) 3 = build/push failed (no on-chain tx attempted) 4 = build OK but on-chain tx failed (image already pushed; re-run reuses it) Wrap prepare* (build) and execute* (on-chain) in stage-labeled try/catch in both deploy.ts and upgrade.ts; the non-interactive precheck now exits 2. On-chain failure message states the image was already built+pushed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 72 ++++++++++++------- .../cli/src/commands/compute/app/upgrade.ts | 63 ++++++++++------ .../cli/src/utils/__tests__/exitCodes.test.ts | 21 ++++++ packages/cli/src/utils/exitCodes.ts | 26 +++++++ 4 files changed, 136 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/exitCodes.test.ts create mode 100644 packages/cli/src/utils/exitCodes.ts diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 430c4cde..80117e77 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -43,6 +43,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; +import { EXIT_CODES, errorMessage } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppDeploy extends Command { @@ -185,7 +186,7 @@ export default class AppDeploy extends Command { if (missing.length > 0) { this.error( `Missing required input(s) for non-interactive deploy:\n - ${missing.join("\n - ")}`, - { exit: 1 }, + { exit: EXIT_CODES.INVALID_INPUT }, ); } } @@ -470,28 +471,38 @@ export default class AppDeploy extends Command { // the normal prepareDeploy path so that layerRemoteImageIfNeeded can // add the ecloud runtime layer (startup script, KMS client, Caddy) if // the image doesn't already have it. - const { prepared, gasEstimate } = - verifiableMode === "git" - ? await compute.app.prepareDeployFromVerifiableBuild({ - name: appName, - imageRef, - imageDigest: verifiableImageDigest!, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - billTo: "developer", - }) - : await compute.app.prepareDeploy({ - name: appName, - dockerfile: dockerfilePath, - imageRef, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - billTo: "developer", - }); + // Build/push stage — failures here mean no image was produced and no + // on-chain tx was attempted. Distinct exit code so callers don't confuse + // it with an on-chain failure (RND-591). + let prepared, gasEstimate; + try { + ({ prepared, gasEstimate } = + verifiableMode === "git" + ? await compute.app.prepareDeployFromVerifiableBuild({ + name: appName, + imageRef, + imageDigest: verifiableImageDigest!, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + billTo: "developer", + }) + : await compute.app.prepareDeploy({ + name: appName, + dockerfile: dockerfilePath, + imageRef, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + billTo: "developer", + })); + } catch (err) { + this.error(`Build/push failed (no deployment was attempted): ${errorMessage(err)}`, { + exit: EXIT_CODES.BUILD_FAILED, + }); + } // 9. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); @@ -515,8 +526,19 @@ export default class AppDeploy extends Command { } } - // 10. Execute the deployment - const res = await compute.app.executeDeploy(prepared, finalTx); + // 10. Execute the deployment (on-chain stage). The image is already + // built+pushed at this point; a failure here is distinct from a build + // failure and a re-run will reuse the pushed image (RND-591). + let res; + try { + res = await compute.app.executeDeploy(prepared, finalTx); + } catch (err) { + this.error( + `On-chain deployment failed after the image was built and pushed: ${errorMessage(err)}\n` + + `The image is already pushed — re-running deploy will reuse it.`, + { exit: EXIT_CODES.ONCHAIN_FAILED }, + ); + } // 11. Collect app profile while deployment is in progress (optional) if (!flags["skip-profile"]) { diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index ef9b1d72..c6f4dfe1 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -40,6 +40,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; +import { EXIT_CODES, errorMessage } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppUpgrade extends Command { @@ -174,7 +175,7 @@ export default class AppUpgrade extends Command { if (missing.length > 0) { this.error( `Missing required input(s) for non-interactive upgrade:\n - ${missing.join("\n - ")}`, - { exit: 1 }, + { exit: EXIT_CODES.INVALID_INPUT }, ); } } @@ -415,24 +416,33 @@ export default class AppUpgrade extends Command { // the normal prepareUpgrade path so that layerRemoteImageIfNeeded can // add the ecloud runtime layer (startup script, KMS client, Caddy) if // the image doesn't already have it. - const { prepared, gasEstimate } = - verifiableMode === "git" - ? await compute.app.prepareUpgradeFromVerifiableBuild(appID, { - imageRef, - imageDigest: verifiableImageDigest!, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - }) - : await compute.app.prepareUpgrade(appID, { - dockerfile: dockerfilePath, - imageRef, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - }); + // Build/push stage — failures here mean no image was produced and no + // on-chain tx was attempted (RND-591). + let prepared, gasEstimate; + try { + ({ prepared, gasEstimate } = + verifiableMode === "git" + ? await compute.app.prepareUpgradeFromVerifiableBuild(appID, { + imageRef, + imageDigest: verifiableImageDigest!, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + }) + : await compute.app.prepareUpgrade(appID, { + dockerfile: dockerfilePath, + imageRef, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + })); + } catch (err) { + this.error(`Build/push failed (no upgrade was attempted): ${errorMessage(err)}`, { + exit: EXIT_CODES.BUILD_FAILED, + }); + } // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); @@ -456,8 +466,19 @@ export default class AppUpgrade extends Command { } } - // 11. Execute the upgrade - const res = await compute.app.executeUpgrade(prepared, finalTx); + // 11. Execute the upgrade (on-chain stage). Image already built+pushed; + // a failure here is distinct from a build failure and a re-run reuses the + // pushed image (RND-591). + let res; + try { + res = await compute.app.executeUpgrade(prepared, finalTx); + } catch (err) { + this.error( + `On-chain upgrade failed after the image was built and pushed: ${errorMessage(err)}\n` + + `The image is already pushed — re-running upgrade will reuse it.`, + { exit: EXIT_CODES.ONCHAIN_FAILED }, + ); + } // 12. Watch until upgrade completes try { diff --git a/packages/cli/src/utils/__tests__/exitCodes.test.ts b/packages/cli/src/utils/__tests__/exitCodes.test.ts new file mode 100644 index 00000000..caa602c7 --- /dev/null +++ b/packages/cli/src/utils/__tests__/exitCodes.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { EXIT_CODES, errorMessage } from "../exitCodes"; + +describe("deploy/upgrade exit codes (RND-591)", () => { + it("uses distinct, stable codes per failure stage", () => { + expect(EXIT_CODES.INVALID_INPUT).toBe(2); + expect(EXIT_CODES.BUILD_FAILED).toBe(3); + expect(EXIT_CODES.ONCHAIN_FAILED).toBe(4); + // All distinct. + expect(new Set(Object.values(EXIT_CODES)).size).toBe(3); + }); + + it("errorMessage extracts Error.message", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + it("errorMessage stringifies non-Error values", () => { + expect(errorMessage("plain string")).toBe("plain string"); + expect(errorMessage(42)).toBe("42"); + }); +}); diff --git a/packages/cli/src/utils/exitCodes.ts b/packages/cli/src/utils/exitCodes.ts new file mode 100644 index 00000000..da16ce70 --- /dev/null +++ b/packages/cli/src/utils/exitCodes.ts @@ -0,0 +1,26 @@ +/** + * Distinct process exit codes for deploy/upgrade so a caller (CI, agent) keying + * off exit status can tell *which stage* failed (RND-591). + * + * A ~7-minute build that succeeds and then fails on-chain must be + * distinguishable from a build that never produced an image. + * + * 1 generic / unclassified error (oclif default) + * 2 invalid or missing input — fails before any build + * 3 build/push failed — no on-chain transaction was attempted + * 4 build/push succeeded but the on-chain transaction failed + * (the image is already built+pushed; a re-run reuses it) + */ +export const EXIT_CODES = { + INVALID_INPUT: 2, + BUILD_FAILED: 3, + ONCHAIN_FAILED: 4, +} as const; + +export type DeployStageExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; + +/** Extract a human-readable message from an unknown thrown value. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} From 55cd43c39c000018e16d633d9a53175ff5338eae Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 19:34:29 -0300 Subject: [PATCH 44/55] docs(cli): document deploy/upgrade exit codes 2/3/4 in agent skill (RND-591) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/skills/deploy/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/skills/deploy/SKILL.md b/packages/cli/skills/deploy/SKILL.md index 4cc0e149..b580dada 100644 --- a/packages/cli/skills/deploy/SKILL.md +++ b/packages/cli/skills/deploy/SKILL.md @@ -22,7 +22,11 @@ You are deploying to EigenCloud TEE (Trusted Execution Environment) infrastructu 2. **Always use hex app IDs (0x...), never display names.** Name lookup is profile-based and silently fails on some commands (confirmed: `app info` by name returns `not found` while the same app works fine by ID). -3. **The CLI returns exit 1 for all errors.** There are no granular exit codes. Parse stdout/stderr content to determine what went wrong. +3. **Exit codes.** Most commands return exit `1` for any error (parse stdout/stderr to see what went wrong). **`app deploy` and `app upgrade` are the exception** — they emit distinct codes so you can branch without parsing text: + - `2` — invalid/missing input; failed **before** any build. Fix flags and re-run. + - `3` — build/push failed; **no on-chain transaction was attempted**. No image was produced. + - `4` — build/push succeeded but the **on-chain transaction failed**. The image is already built and pushed; re-running deploy/upgrade reuses it (you are not paying for another ~7-min build). + - `1` — generic/unclassified error. 4. **Env vars are encrypted client-side** before transmission. They are only decrypted inside the TEE. Never visible in logs or API responses. From aa937d363d7fa40ffc7df240650e3296c98c380d Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Wed, 3 Jun 2026 21:27:43 -0300 Subject: [PATCH 45/55] =?UTF-8?q?fix(cli,sdk):=20harden=20amd64=20enforcem?= =?UTF-8?q?ent=20=E2=80=94=20close=20assume-amd64=20hole=20+=20pre-flight?= =?UTF-8?q?=20check=20(RND-597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps that let an arm64 image deploy and crash on first request in the TEE: 1. digest.ts: the 'architecture undetectable' branch assumed linux/amd64 and returned without verifying. Now throws createPlatformErrorMessage (fail closed). 2. prepare.ts: verify the remote --image-ref is linux/amd64 (docker manifest inspect, no pull) BEFORE layerRemoteImageIfNeeded, so an arm64 ref fails in ~seconds with the buildx/--verifiable remediation instead of after a multi-minute pull+layer+push. 3. dockerhub.ts: resolveDockerHubImageDigest (prebuilt verifiable images) now asserts a linux/amd64 manifest — checks the index for an amd64 entry, or the config blob's architecture for single-platform — rejecting otherwise. --verifiable --repo --commit is unchanged (server-side build, no local Docker; never hits these paths). Tests: digest.test.ts (undetectable→throw, single-platform arm64, multi-platform no-amd64) + dockerhub.test.ts (multi/single platform accept+reject). 40 SDK + 99 CLI tests pass. E2E sepolia-dev: arm64 --image-ref rejected pre-push in ~16s (exit 3); amd64 passes pre-flight and proceeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/utils/__tests__/dockerhub.test.ts | 83 +++++++++++++++++ packages/cli/src/utils/dockerhub.ts | 88 +++++++++++++++++++ .../common/registry/__tests__/digest.test.ts | 59 +++++++++++++ .../sdk/src/client/common/registry/digest.ts | 17 ++-- .../sdk/src/client/common/release/prepare.ts | 8 ++ 5 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/dockerhub.test.ts create mode 100644 packages/sdk/src/client/common/registry/__tests__/digest.test.ts diff --git a/packages/cli/src/utils/__tests__/dockerhub.test.ts b/packages/cli/src/utils/__tests__/dockerhub.test.ts new file mode 100644 index 00000000..43dda6c7 --- /dev/null +++ b/packages/cli/src/utils/__tests__/dockerhub.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveDockerHubImageDigest } from "../dockerhub"; + +const IMAGE = "docker.io/eigenlayer/eigencloud-containers:demo"; +const DIGEST = "sha256:" + "a".repeat(64); + +/** Build a fetch stub that routes by URL/method to canned responses. */ +function stubFetch(handlers: { + manifestList?: unknown; // multi-platform index returned for the manifest GET + singleManifest?: { config: { digest: string } }; // single-platform manifest + config?: { os?: string; architecture?: string }; // config blob +}) { + const json = (body: unknown, headers: Record = {}) => + ({ + ok: true, + status: 200, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + json: async () => body, + text: async () => JSON.stringify(body), + }) as unknown as Response; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL, init?: { method?: string }) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + + if (url.includes("auth.docker.io/token")) return json({ token: "t" }); + + if (url.includes("/manifests/")) { + // The digest HEAD/GET: return the content-digest header. + if (method === "HEAD") return json({}, { "docker-content-digest": DIGEST }); + // The platform-check GET (Accept includes index types) OR digest GET fallback. + if (handlers.manifestList) + return json(handlers.manifestList, { "docker-content-digest": DIGEST }); + return json(handlers.singleManifest ?? {}, { "docker-content-digest": DIGEST }); + } + + if (url.includes("/blobs/")) return json(handlers.config ?? {}); + + throw new Error(`unexpected fetch: ${method} ${url}`); + }), + ); +} + +describe("resolveDockerHubImageDigest amd64 enforcement (RND-597)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("accepts a multi-platform image that includes linux/amd64", async () => { + stubFetch({ + manifestList: { + manifests: [ + { platform: { os: "linux", architecture: "arm64" } }, + { platform: { os: "linux", architecture: "amd64" } }, + ], + }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).resolves.toBe(DIGEST); + }); + + it("rejects a multi-platform image with no linux/amd64 entry", async () => { + stubFetch({ + manifestList: { manifests: [{ platform: { os: "linux", architecture: "arm64" } }] }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).rejects.toThrow(/linux\/amd64/); + }); + + it("accepts a single-platform linux/amd64 image (config blob)", async () => { + stubFetch({ + singleManifest: { config: { digest: "sha256:" + "c".repeat(64) } }, + config: { os: "linux", architecture: "amd64" }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).resolves.toBe(DIGEST); + }); + + it("rejects a single-platform arm64 image", async () => { + stubFetch({ + singleManifest: { config: { digest: "sha256:" + "c".repeat(64) } }, + config: { os: "linux", architecture: "arm64" }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).rejects.toThrow(/linux\/amd64/); + }); +}); diff --git a/packages/cli/src/utils/dockerhub.ts b/packages/cli/src/utils/dockerhub.ts index 70b29373..569046d4 100644 --- a/packages/cli/src/utils/dockerhub.ts +++ b/packages/cli/src/utils/dockerhub.ts @@ -102,5 +102,93 @@ export async function resolveDockerHubImageDigest(imageRef: string): Promise { + const base = `https://registry-1.docker.io/v2/${owner}/${repo}`; + const headers = { Authorization: `Bearer ${token}`, Accept: AMD64_ACCEPT }; + + const res = await fetch(`${base}/manifests/${encodeURIComponent(tag)}`, { headers }); + if (!res.ok) { + const body = await safeReadText(res); + throw new Error( + `Failed to read manifest for ${imageRef} (${res.status}): ${body || res.statusText}`, + ); + } + const manifest = (await res.json()) as { + manifests?: Array<{ platform?: { os?: string; architecture?: string } }>; + config?: { digest?: string }; + architecture?: string; + os?: string; + }; + + const isAmd64 = (os?: string, arch?: string) => os === "linux" && arch === "amd64"; + + // Multi-platform: scan the index entries. + if (Array.isArray(manifest.manifests) && manifest.manifests.length > 0) { + const platforms = manifest.manifests.map((m) => + m.platform ? `${m.platform.os}/${m.platform.architecture}` : "unknown", + ); + if (manifest.manifests.some((m) => isAmd64(m.platform?.os, m.platform?.architecture))) { + return; + } + throw amd64Error(imageRef, platforms); + } + + // Single-platform: the architecture lives in the config blob, not the manifest. + const configDigest = manifest.config?.digest; + if (!configDigest) { + throw amd64Error(imageRef, ["unknown (no platform info in manifest)"]); + } + const cfgRes = await fetch(`${base}/blobs/${configDigest}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!cfgRes.ok) { + throw amd64Error(imageRef, ["unknown (could not read image config)"]); + } + const cfg = (await cfgRes.json()) as { architecture?: string; os?: string }; + if (isAmd64(cfg.os, cfg.architecture)) { + return; + } + throw amd64Error(imageRef, [`${cfg.os ?? "unknown"}/${cfg.architecture ?? "unknown"}`]); +} + +function amd64Error(imageRef: string, platforms: string[]): Error { + return new Error( + `ecloud requires linux/amd64 images for TEE deployment.\n\n` + + `Image: ${imageRef}\n` + + `Found platform(s): ${platforms.join(", ")}\n` + + `Required platform: linux/amd64\n\n` + + `To fix: rebuild for linux/amd64 (e.g. docker buildx build --platform linux/amd64 ... --push), ` + + `or use a verifiable build (--verifiable --repo --commit ), which builds server-side.`, + ); +} diff --git a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts new file mode 100644 index 00000000..9f5e4bc8 --- /dev/null +++ b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock child_process.execFile so promisify(execFile) is drivable. The real +// signature is execFile(cmd, args, opts, cb); promisify calls the (err, {stdout}) +// node-style callback. We route by the docker subcommand. +const responses: Record = {}; +vi.mock("child_process", () => ({ + execFile: ( + _cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, res?: { stdout: string; stderr: string }) => void, + ) => { + const sub = args[0]; // "manifest" | "inspect" + const key = sub === "manifest" ? "manifest" : "inspect"; + const out = responses[key]; + if (out === undefined) { + cb(new Error(`no mock for docker ${args.join(" ")}`)); + return; + } + cb(null, { stdout: out, stderr: "" }); + }, +})); + +import { getImageDigestAndName } from "../digest"; + +describe("getImageDigestAndName amd64 enforcement (RND-597)", () => { + afterEach(() => { + for (const k of Object.keys(responses)) delete responses[k]; + vi.clearAllMocks(); + }); + + it("throws (fail closed) when architecture is undetectable — no assume-amd64", async () => { + // Single-platform manifest (no .manifests[]), so it calls `docker inspect`, + // whose output has NO Architecture field but the manifest has a config digest. + responses.manifest = JSON.stringify({ config: { digest: "sha256:" + "b".repeat(64) } }); + responses.inspect = JSON.stringify([{ Os: "linux" /* Architecture missing */ }]); + + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); + + it("rejects a single-platform arm64 image", async () => { + responses.manifest = JSON.stringify({ config: { digest: "sha256:" + "b".repeat(64) } }); + responses.inspect = JSON.stringify([ + { Os: "linux", Architecture: "arm64", RepoDigests: ["x@sha256:" + "c".repeat(64)] }, + ]); + + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); + + it("rejects a multi-platform image with no linux/amd64 entry", async () => { + responses.manifest = JSON.stringify({ + manifests: [ + { digest: "sha256:" + "d".repeat(64), platform: { os: "linux", architecture: "arm64" } }, + ], + }); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); +}); diff --git a/packages/sdk/src/client/common/registry/digest.ts b/packages/sdk/src/client/common/registry/digest.ts index a7452431..72425960 100644 --- a/packages/sdk/src/client/common/registry/digest.ts +++ b/packages/sdk/src/client/common/registry/digest.ts @@ -116,18 +116,11 @@ async function extractDigestFromSinglePlatform( : null; if (!config) { - // Try to get from manifest config digest - if (manifest.config?.digest) { - const digest = hexStringToBytes32(manifest.config.digest); - const registry = extractRegistryName(imageRef); - // Assume linux/amd64 if we can't determine platform - return { - digest, - registry, - platform: DOCKER_PLATFORM, - }; - } - throw new Error(`Could not determine platform for ${imageRef}`); + // Architecture is undetectable from `docker inspect`. Previously this + // assumed linux/amd64 and deployed anyway — the silent hole that let an + // arm64 image through to crash on first request in the TEE. Fail closed + // instead: refuse rather than guess the platform (RND-597). + throw createPlatformErrorMessage(imageRef, ["unknown (could not determine architecture)"]); } const platform = `${config.os}/${config.architecture}`; diff --git a/packages/sdk/src/client/common/release/prepare.ts b/packages/sdk/src/client/common/release/prepare.ts index 59fbc70e..6c2d7c83 100644 --- a/packages/sdk/src/client/common/release/prepare.ts +++ b/packages/sdk/src/client/common/release/prepare.ts @@ -71,6 +71,14 @@ export async function prepareRelease( logger.info(`Waiting ${REGISTRY_PROPAGATION_WAIT_SECONDS} seconds for registry propagation...`); await new Promise((resolve) => setTimeout(resolve, REGISTRY_PROPAGATION_WAIT_SECONDS * 1000)); } else { + // Pre-flight: verify the remote image is linux/amd64 BEFORE the slow + // pull+layer+push (RND-597). getImageDigestAndName runs `docker manifest + // inspect` (no pull) and throws the platform-remediation error for a + // non-amd64 image, so an arm64 --image-ref fails in seconds instead of + // minutes — and never slips through to crash in the TEE. + logger.info("Verifying image platform (linux/amd64)..."); + await getImageDigestAndName(imageRef); + // Layer remote image if needed logger.info("Checking if image needs layering..."); finalImageRef = await layerRemoteImageIfNeeded( From 0e48c7e236ae4ea919cca2bee0d6af81ec4d2fb0 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 00:04:14 -0300 Subject: [PATCH 46/55] feat(cli,sdk): block deploy/upgrade when wallet ETH < estimated gas (RND-596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute credits do not pay on-chain gas (paid by the EOA via EIP-7702), so an agent funded only with credits had its deploy tx revert with no machine-readable pre-check. Add a typed pre-flight gate in the SDK so BOTH CLI and SDK/agent paths are protected: - New InsufficientGasError + assertSufficientGas(publicClient, address, gasEstimate) in common/gas/insufficientGas.ts; threshold is gasEstimate.maxCostWei (not zero — dust below cost still fails). Exported from the SDK index. - Call it at all 4 prepare* gas-estimate sites (deploy + upgrade, normal + verifiable), right after estimateBatchGas, before returning. - billing status now shows the wallet's on-chain ETH (best-effort) so the credit-vs-gas gap is visible pre-deploy. Tests: assertSufficientGas (above/equal/dust-below/error-shape). 44 SDK + 99 CLI tests pass. E2E sepolia-dev: a 0.0044-ETH wallet is blocked with 'needs ~0.0198 ETH ... credits do not pay on-chain gas'; billing status shows the ETH line. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/billing/status.ts | 30 +++++++- .../gas/__tests__/insufficientGas.test.ts | 70 +++++++++++++++++++ .../src/client/common/gas/insufficientGas.ts | 56 +++++++++++++++ packages/sdk/src/client/index.ts | 1 + .../src/client/modules/compute/app/deploy.ts | 15 ++++ .../src/client/modules/compute/app/upgrade.ts | 15 ++++ 6 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts create mode 100644 packages/sdk/src/client/common/gas/insufficientGas.ts diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index 2089dd04..434b9b0a 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -1,6 +1,9 @@ import { Command, Flags } from "@oclif/core"; import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; +import { getEnvironmentConfig } from "@layr-labs/ecloud-sdk"; +import { createViemClients } from "../../utils/viemClients"; +import { formatEther } from "viem"; import chalk from "chalk"; import { withTelemetry } from "../../telemetry"; @@ -58,6 +61,29 @@ 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 (RND-596). + // 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 { + // ignore — ETH balance is informational + } + this.log(` Status: ${formatStatus(result.subscriptionStatus)}`); this.log(` Product: ${result.productId}`); @@ -75,7 +101,9 @@ export default class BillingStatus extends Command { 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"; + 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)`, ); diff --git a/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts b/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts new file mode 100644 index 00000000..115bf5eb --- /dev/null +++ b/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { Address, PublicClient } from "viem"; +import { assertSufficientGas, InsufficientGasError } from "../insufficientGas"; +import type { GasEstimate } from "../../contract/caller"; + +const ADDR = "0x540d6701c396f77c3601FC34585107497ED71495" as Address; + +function gasEstimate(maxCostWei: bigint): GasEstimate { + return { + gasLimit: BigInt(21000), + maxFeePerGas: BigInt(1), + maxPriorityFeePerGas: BigInt(1), + maxCostWei, + maxCostEth: "0", + }; +} + +function clientWithBalance(balanceWei: bigint): PublicClient { + return { getBalance: async () => balanceWei } as unknown as PublicClient; +} + +describe("assertSufficientGas (RND-596)", () => { + it("passes when balance exceeds the estimate", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(10)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).resolves.toBeUndefined(); + }); + + it("passes when balance exactly equals the estimate", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(5)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).resolves.toBeUndefined(); + }); + + it("throws InsufficientGasError on dust below the estimate (not just zero)", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(4)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).rejects.toBeInstanceOf(InsufficientGasError); + }); + + it("error carries required/available and a credits-don't-pay-gas message", async () => { + try { + await assertSufficientGas({ + publicClient: clientWithBalance(BigInt(0)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(7)), + }); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(InsufficientGasError); + const e = err as InsufficientGasError; + expect(e.requiredWei).toBe(BigInt(7)); + expect(e.availableWei).toBe(BigInt(0)); + expect(e.address).toBe(ADDR); + expect(e.message).toMatch(/credits do not pay on-chain gas/i); + } + }); +}); diff --git a/packages/sdk/src/client/common/gas/insufficientGas.ts b/packages/sdk/src/client/common/gas/insufficientGas.ts new file mode 100644 index 00000000..1d76eaaa --- /dev/null +++ b/packages/sdk/src/client/common/gas/insufficientGas.ts @@ -0,0 +1,56 @@ +import type { Address, PublicClient } from "viem"; +import { formatEther } from "viem"; +import type { GasEstimate } from "../contract/caller"; + +/** + * Thrown when the wallet's native ETH balance is below the estimated on-chain + * gas cost for a deploy/upgrade. + * + * Compute credits (Stripe / USDC-converted) do NOT pay on-chain gas — gas is + * paid by the user's EOA at send time via EIP-7702. Without this pre-flight the + * transaction reverts only after submission, which to an agent is + * indistinguishable from other failures (RND-596). + */ +export class InsufficientGasError extends Error { + public readonly address: Address; + public readonly requiredWei: bigint; + public readonly availableWei: bigint; + public readonly requiredEth: string; + public readonly availableEth: string; + + constructor(args: { address: Address; requiredWei: bigint; availableWei: bigint }) { + const requiredEth = formatEther(args.requiredWei); + const availableEth = formatEther(args.availableWei); + super( + `Insufficient ETH for gas: wallet ${args.address} has ${availableEth} ETH but ` + + `this transaction needs ~${requiredEth} ETH.\n` + + `Compute credits do not pay on-chain gas — fund the wallet with ETH and retry.`, + ); + this.name = "InsufficientGasError"; + this.address = args.address; + this.requiredWei = args.requiredWei; + this.availableWei = args.availableWei; + this.requiredEth = requiredEth; + this.availableEth = availableEth; + } +} + +/** + * Pre-flight gate: throw {@link InsufficientGasError} when the wallet's ETH + * balance is below the estimated gas cost. The threshold is the gas estimate + * (`maxCostWei`), not zero — dust below the cost must still fail. + */ +export async function assertSufficientGas(args: { + publicClient: PublicClient; + address: Address; + gasEstimate: GasEstimate; +}): Promise { + const availableWei = await args.publicClient.getBalance({ address: args.address }); + if (availableWei < args.gasEstimate.maxCostWei) { + throw new InsufficientGasError({ + address: args.address, + requiredWei: args.gasEstimate.maxCostWei, + availableWei, + }); + } +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index eb5eadef..2246f1d2 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -51,6 +51,7 @@ export { type WatchUpgradeOptions, } from "./modules/compute/app/upgrade"; export { WatchTimeoutError, WATCH_DEFAULT_TIMEOUT_SECONDS } from "./common/contract/watcher"; +export { InsufficientGasError, assertSufficientGas } from "./common/gas/insufficientGas"; // Export compute module for standalone use export { diff --git a/packages/sdk/src/client/modules/compute/app/deploy.ts b/packages/sdk/src/client/modules/compute/app/deploy.ts index 27ebcf0c..5eec33d1 100644 --- a/packages/sdk/src/client/modules/compute/app/deploy.ts +++ b/packages/sdk/src/client/modules/compute/app/deploy.ts @@ -29,6 +29,7 @@ import { } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; import { type GasEstimate } from "../../../common/contract/caller"; +import { assertSufficientGas } from "../../../common/gas/insufficientGas"; import { watchUntilRunning } from "../../../common/contract/watcher"; import { validateAppName, @@ -271,6 +272,13 @@ export async function prepareDeployFromVerifiableBuild( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedDeployData = { appId: batch.appId, @@ -655,6 +663,13 @@ export async function prepareDeploy( authorizationList, }); + // 10b. Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedDeployData = { appId: batch.appId, diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index bf587079..b9151eca 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -27,6 +27,7 @@ import { type GasEstimate, } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; +import { assertSufficientGas } from "../../../common/gas/insufficientGas"; import { watchUntilUpgradeComplete } from "../../../common/contract/watcher"; import { validateAppID, @@ -201,6 +202,13 @@ export async function prepareUpgradeFromVerifiableBuild( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedUpgradeData = { appId: batch.appId, @@ -489,6 +497,13 @@ export async function prepareUpgrade( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedUpgradeData = { appId: batch.appId, From 09286b15ef545fbf753a0a9bd7ad3fca37a45737 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 09:58:26 -0300 Subject: [PATCH 47/55] chore: remove Linear ticket IDs from source code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip RND-* ticket references from code comments and test names across the CLI and SDK packages. Ticket tracking belongs in the PR/commit/branch, not in committed source. Comment-only and describe()-label changes — no behavior change; all affected tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/__tests__/flags.test.ts | 4 ++-- packages/cli/src/commands/billing/status.ts | 2 +- .../compute/app/__tests__/status.test.ts | 2 +- .../cli/src/commands/compute/app/deploy.ts | 6 ++--- .../cli/src/commands/compute/app/status.ts | 2 +- .../cli/src/commands/compute/app/upgrade.ts | 6 ++--- .../init/__tests__/version-check.test.ts | 2 +- packages/cli/src/hooks/init/version-check.ts | 2 +- .../cli/src/utils/__tests__/dockerhub.test.ts | 2 +- .../cli/src/utils/__tests__/exitCodes.test.ts | 2 +- .../cli/src/utils/__tests__/prompts.test.ts | 22 +++++++++---------- packages/cli/src/utils/dockerhub.ts | 4 ++-- packages/cli/src/utils/exitCodes.ts | 2 +- packages/cli/src/utils/prompts.ts | 4 ++-- .../gas/__tests__/insufficientGas.test.ts | 2 +- .../src/client/common/gas/insufficientGas.ts | 2 +- .../common/registry/__tests__/digest.test.ts | 2 +- .../sdk/src/client/common/registry/digest.ts | 2 +- .../sdk/src/client/common/release/prepare.ts | 2 +- .../common/utils/__tests__/retry.test.ts | 4 ++-- packages/sdk/src/client/common/utils/retry.ts | 2 +- 21 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/__tests__/flags.test.ts b/packages/cli/src/__tests__/flags.test.ts index a9a56ec6..f1590e8f 100644 --- a/packages/cli/src/__tests__/flags.test.ts +++ b/packages/cli/src/__tests__/flags.test.ts @@ -13,11 +13,11 @@ import { getBuildType } from "@layr-labs/ecloud-sdk"; import { getDefaultEnvironment } from "../utils/globalConfig"; /** - * RND-589: prod builds default --environment to mainnet-alpha (was sepolia); + * Prod builds default --environment to mainnet-alpha (was sepolia); * dev builds stay sepolia-dev; an explicit configured default always wins. * The default is an async thunk on the oclif flag definition. */ -describe("commonFlags.environment default (RND-589)", () => { +describe("commonFlags.environment default", () => { // oclif stores the default function on the flag's `default` property. const resolveDefault = () => (commonFlags.environment as unknown as { default: () => Promise }).default(); diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index 434b9b0a..4d6f1f3a 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -63,7 +63,7 @@ export default class BillingStatus extends Command { 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 (RND-596). + // 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"]; diff --git a/packages/cli/src/commands/compute/app/__tests__/status.test.ts b/packages/cli/src/commands/compute/app/__tests__/status.test.ts index 6f60f22c..888ef23b 100644 --- a/packages/cli/src/commands/compute/app/__tests__/status.test.ts +++ b/packages/cli/src/commands/compute/app/__tests__/status.test.ts @@ -30,7 +30,7 @@ vi.mock("../../../../client", () => ({ createComputeClient: vi.fn(async () => ({ app: { watchDeployment } })), })); -describe("compute app status (RND-592)", () => { +describe("compute app status", () => { let logOutput: string[]; beforeEach(() => { diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 80117e77..61c4b2b0 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -170,7 +170,7 @@ export default class AppDeploy extends Command { const { flags } = await this.parse(AppDeploy); // Non-interactive: report every missing required input at once instead of - // failing one prompt at a time (RND-589). + // failing one prompt at a time. if (isNonInteractive(flags)) { const missing = collectMissingRequiredInputs( { @@ -473,7 +473,7 @@ export default class AppDeploy extends Command { // the image doesn't already have it. // Build/push stage — failures here mean no image was produced and no // on-chain tx was attempted. Distinct exit code so callers don't confuse - // it with an on-chain failure (RND-591). + // it with an on-chain failure. let prepared, gasEstimate; try { ({ prepared, gasEstimate } = @@ -528,7 +528,7 @@ export default class AppDeploy extends Command { // 10. Execute the deployment (on-chain stage). The image is already // built+pushed at this point; a failure here is distinct from a build - // failure and a re-run will reuse the pushed image (RND-591). + // failure and a re-run will reuse the pushed image. let res; try { res = await compute.app.executeDeploy(prepared, finalTx); diff --git a/packages/cli/src/commands/compute/app/status.ts b/packages/cli/src/commands/compute/app/status.ts index 97bcb8c4..cb19766e 100644 --- a/packages/cli/src/commands/compute/app/status.ts +++ b/packages/cli/src/commands/compute/app/status.ts @@ -70,7 +70,7 @@ export default class AppStatus extends Command { }); if (flags.wait) { - // Reuse the bounded watch machinery (RND-568/569): polls server-side at a + // Reuse the bounded watch machinery: polls server-side at a // fixed cadence with 429/5xx backoff, throws WatchTimeoutError on timeout. const compute = await createComputeClient(validatedFlags); try { diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index c6f4dfe1..4267cd43 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -157,7 +157,7 @@ export default class AppUpgrade extends Command { const appIdInput = args["app-id"] ?? process.env.ECLOUD_APP_ID; // Non-interactive: report every missing required input at once instead of - // failing one prompt at a time (RND-589). + // failing one prompt at a time. if (isNonInteractive(flags)) { const missing = collectMissingRequiredInputs( { @@ -417,7 +417,7 @@ export default class AppUpgrade extends Command { // add the ecloud runtime layer (startup script, KMS client, Caddy) if // the image doesn't already have it. // Build/push stage — failures here mean no image was produced and no - // on-chain tx was attempted (RND-591). + // on-chain tx was attempted. let prepared, gasEstimate; try { ({ prepared, gasEstimate } = @@ -468,7 +468,7 @@ export default class AppUpgrade extends Command { // 11. Execute the upgrade (on-chain stage). Image already built+pushed; // a failure here is distinct from a build failure and a re-run reuses the - // pushed image (RND-591). + // pushed image. let res; try { res = await compute.app.executeUpgrade(prepared, finalTx); diff --git a/packages/cli/src/hooks/init/__tests__/version-check.test.ts b/packages/cli/src/hooks/init/__tests__/version-check.test.ts index 9e382ff8..cb1d37fe 100644 --- a/packages/cli/src/hooks/init/__tests__/version-check.test.ts +++ b/packages/cli/src/hooks/init/__tests__/version-check.test.ts @@ -110,7 +110,7 @@ describe("version-check init hook", () => { expect(upgradePackage).not.toHaveBeenCalled(); }); - // RND-589: in non-interactive mode the hook must not block on the update + // In non-interactive mode the hook must not block on the update // prompt (it ran before the command and read as a failure in CI/agents). it("does not prompt in non-interactive mode (no TTY)", async () => { process.stdin.isTTY = false; diff --git a/packages/cli/src/hooks/init/version-check.ts b/packages/cli/src/hooks/init/version-check.ts index 4ae0d374..16f29d38 100644 --- a/packages/cli/src/hooks/init/version-check.ts +++ b/packages/cli/src/hooks/init/version-check.ts @@ -117,7 +117,7 @@ const hook: Hook<"init"> = async function (options) { // Non-interactive (CI / no TTY): never block the command on an update prompt. // The hook runs before the command parses flags, so it cannot see - // --non-interactive; CI and isTTY cover the agent/CI failure mode (RND-589). + // --non-interactive; CI and isTTY cover the agent/CI failure mode. if (process.env.CI === "true" || !process.stdin.isTTY) { globalConfig.last_version_check = now; saveGlobalConfig(globalConfig); diff --git a/packages/cli/src/utils/__tests__/dockerhub.test.ts b/packages/cli/src/utils/__tests__/dockerhub.test.ts index 43dda6c7..bc48e4bf 100644 --- a/packages/cli/src/utils/__tests__/dockerhub.test.ts +++ b/packages/cli/src/utils/__tests__/dockerhub.test.ts @@ -43,7 +43,7 @@ function stubFetch(handlers: { ); } -describe("resolveDockerHubImageDigest amd64 enforcement (RND-597)", () => { +describe("resolveDockerHubImageDigest amd64 enforcement", () => { afterEach(() => vi.unstubAllGlobals()); it("accepts a multi-platform image that includes linux/amd64", async () => { diff --git a/packages/cli/src/utils/__tests__/exitCodes.test.ts b/packages/cli/src/utils/__tests__/exitCodes.test.ts index caa602c7..871a07f1 100644 --- a/packages/cli/src/utils/__tests__/exitCodes.test.ts +++ b/packages/cli/src/utils/__tests__/exitCodes.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { EXIT_CODES, errorMessage } from "../exitCodes"; -describe("deploy/upgrade exit codes (RND-591)", () => { +describe("deploy/upgrade exit codes", () => { it("uses distinct, stable codes per failure stage", () => { expect(EXIT_CODES.INVALID_INPUT).toBe(2); expect(EXIT_CODES.BUILD_FAILED).toBe(3); diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index 4fd2767f..df78856e 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -80,10 +80,10 @@ describe("prompts non-interactive regressions", () => { await expect(promptUseVerifiableBuild(true)).resolves.toBe(false); }); - // Post RND-568/569 merge: confirmWithDefault returns the default in non-TTY - // mode instead of throwing, so a verifiable-build confirm resolves to false - // (regular build) rather than erroring. RND-589-aligned: optional confirms - // never block a non-interactive run. + // confirmWithDefault returns the default in non-TTY mode instead of + // throwing, so a verifiable-build confirm resolves to false (regular + // build) rather than erroring. Optional confirms never block a + // non-interactive run. it("resolves to false (regular build) when force is false in non-TTY mode", async () => { process.stdin.isTTY = false; await expect(promptUseVerifiableBuild(false)).resolves.toBe(false); @@ -97,12 +97,12 @@ describe("prompts non-interactive regressions", () => { }); /** - * RND-564 / RND-571: in non-interactive (non-TTY) mode, the optional deploy / - * upgrade prompts must fall back to a safe default with a warning instead of + * In non-interactive (non-TTY) mode, the optional deploy / upgrade prompts + * must fall back to a safe default with a warning instead of * throwing "Cannot prompt in non-interactive mode". Required inputs that have * no safe default (e.g. --instance-type) must still error. */ -describe("non-interactive flag defaulting (RND-564)", () => { +describe("non-interactive flag defaulting", () => { const origIsTTY = process.stdin.isTTY; afterEach(() => { @@ -211,7 +211,7 @@ describe("non-interactive flag defaulting (RND-564)", () => { ); }); - // RND-589: deploy with no instance type defaults to g1-standard-2s in non-interactive mode. + // Deploy with no instance type defaults to g1-standard-2s in non-interactive mode. it("defaults to g1-standard-2s in non-interactive deploy when available", async () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -221,7 +221,7 @@ describe("non-interactive flag defaulting (RND-564)", () => { expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--instance-type.*g1-standard-2s/)); }); - // RND-589: upgrade reuses the currently pinned type (defaultSKU) instead of prompting. + // Upgrade reuses the currently pinned type (defaultSKU) instead of prompting. it("reuses defaultSKU (pinned type) in non-interactive upgrade", async () => { process.stdin.isTTY = false; vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -244,7 +244,7 @@ describe("non-interactive flag defaulting (RND-564)", () => { }); }); -describe("isNonInteractive (RND-589 detection)", () => { +describe("isNonInteractive detection", () => { const origTTY = process.stdin.isTTY; const origCI = process.env.CI; afterEach(() => { @@ -275,7 +275,7 @@ describe("isNonInteractive (RND-589 detection)", () => { }); }); -describe("collectMissingRequiredInputs (RND-589 all-at-once)", () => { +describe("collectMissingRequiredInputs reports all missing at once", () => { it("returns [] when image source + name present", () => { expect(collectMissingRequiredInputs({ imageRef: "r", name: "n" }, "name")).toEqual([]); }); diff --git a/packages/cli/src/utils/dockerhub.ts b/packages/cli/src/utils/dockerhub.ts index 569046d4..fca52205 100644 --- a/packages/cli/src/utils/dockerhub.ts +++ b/packages/cli/src/utils/dockerhub.ts @@ -103,7 +103,7 @@ export async function resolveDockerHubImageDigest(imageRef: string): Promise balanceWei } as unknown as PublicClient; } -describe("assertSufficientGas (RND-596)", () => { +describe("assertSufficientGas", () => { it("passes when balance exceeds the estimate", async () => { await expect( assertSufficientGas({ diff --git a/packages/sdk/src/client/common/gas/insufficientGas.ts b/packages/sdk/src/client/common/gas/insufficientGas.ts index 1d76eaaa..b43d4932 100644 --- a/packages/sdk/src/client/common/gas/insufficientGas.ts +++ b/packages/sdk/src/client/common/gas/insufficientGas.ts @@ -9,7 +9,7 @@ import type { GasEstimate } from "../contract/caller"; * Compute credits (Stripe / USDC-converted) do NOT pay on-chain gas — gas is * paid by the user's EOA at send time via EIP-7702. Without this pre-flight the * transaction reverts only after submission, which to an agent is - * indistinguishable from other failures (RND-596). + * indistinguishable from other failures. */ export class InsufficientGasError extends Error { public readonly address: Address; diff --git a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts index 9f5e4bc8..6ab4f1bb 100644 --- a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts +++ b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts @@ -24,7 +24,7 @@ vi.mock("child_process", () => ({ import { getImageDigestAndName } from "../digest"; -describe("getImageDigestAndName amd64 enforcement (RND-597)", () => { +describe("getImageDigestAndName amd64 enforcement", () => { afterEach(() => { for (const k of Object.keys(responses)) delete responses[k]; vi.clearAllMocks(); diff --git a/packages/sdk/src/client/common/registry/digest.ts b/packages/sdk/src/client/common/registry/digest.ts index 72425960..836bf730 100644 --- a/packages/sdk/src/client/common/registry/digest.ts +++ b/packages/sdk/src/client/common/registry/digest.ts @@ -119,7 +119,7 @@ async function extractDigestFromSinglePlatform( // Architecture is undetectable from `docker inspect`. Previously this // assumed linux/amd64 and deployed anyway — the silent hole that let an // arm64 image through to crash on first request in the TEE. Fail closed - // instead: refuse rather than guess the platform (RND-597). + // instead: refuse rather than guess the platform. throw createPlatformErrorMessage(imageRef, ["unknown (could not determine architecture)"]); } diff --git a/packages/sdk/src/client/common/release/prepare.ts b/packages/sdk/src/client/common/release/prepare.ts index 6c2d7c83..8333d087 100644 --- a/packages/sdk/src/client/common/release/prepare.ts +++ b/packages/sdk/src/client/common/release/prepare.ts @@ -72,7 +72,7 @@ export async function prepareRelease( await new Promise((resolve) => setTimeout(resolve, REGISTRY_PROPAGATION_WAIT_SECONDS * 1000)); } else { // Pre-flight: verify the remote image is linux/amd64 BEFORE the slow - // pull+layer+push (RND-597). getImageDigestAndName runs `docker manifest + // pull+layer+push. getImageDigestAndName runs `docker manifest // inspect` (no pull) and throws the platform-remediation error for a // non-amd64 image, so an arm64 --image-ref fails in seconds instead of // minutes — and never slips through to crash in the TEE. diff --git a/packages/sdk/src/client/common/utils/__tests__/retry.test.ts b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts index 4677d130..c2004861 100644 --- a/packages/sdk/src/client/common/utils/__tests__/retry.test.ts +++ b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts @@ -9,11 +9,11 @@ vi.mock("axios", () => ({ import { requestWithRetry } from "../retry"; /** - * RND-592: requestWithRetry retries 429 (rate limit) AND transient gateway + * requestWithRetry retries 429 (rate limit) AND transient gateway * errors 502/503/504, then returns the last response. 2xx/4xx (other than 429) * are returned immediately without retry. */ -describe("requestWithRetry retryable statuses (RND-592)", () => { +describe("requestWithRetry retryable statuses", () => { afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); diff --git a/packages/sdk/src/client/common/utils/retry.ts b/packages/sdk/src/client/common/utils/retry.ts index 1b07ea3f..5da194d2 100644 --- a/packages/sdk/src/client/common/utils/retry.ts +++ b/packages/sdk/src/client/common/utils/retry.ts @@ -7,7 +7,7 @@ const MAX_BACKOFF_MS = 30000; /** * HTTP statuses worth retrying with backoff: rate limiting (429) and transient * gateway/availability errors (502/503/504). Agents that poll status hit these - * under load, and they are routinely recoverable on retry (RND-592). + * under load, and they are routinely recoverable on retry. */ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); From a8ccb2456a4a5298319caf84032f13a0c618a1c8 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 11:46:22 -0300 Subject: [PATCH 48/55] refactor(cli): add explicit type hints to deploy/upgrade let declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bare `let prepared, gasEstimate;` / `let res;` (implicit any) with explicit annotations in the deploy and upgrade command flows. None take undefined — the catch block calls this.error(..., {exit}) (returns never), so the vars are definitely assigned. Types sourced from the SDK's exported Prepare{Deploy,Upgrade}Result / GasEstimate and Awaited>. No behavior change; deploy/upgrade tests + prettier pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/compute/app/deploy.ts | 6 ++++-- packages/cli/src/commands/compute/app/upgrade.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 61c4b2b0..4fe9430e 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -5,6 +5,7 @@ import { isMainnet, WatchTimeoutError, } from "@layr-labs/ecloud-sdk"; +import type { PrepareDeployResult, GasEstimate } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createComputeClient } from "../../../client"; @@ -474,7 +475,8 @@ export default class AppDeploy extends Command { // Build/push stage — failures here mean no image was produced and no // on-chain tx was attempted. Distinct exit code so callers don't confuse // it with an on-chain failure. - let prepared, gasEstimate; + let prepared: PrepareDeployResult["prepared"]; + let gasEstimate: GasEstimate; try { ({ prepared, gasEstimate } = verifiableMode === "git" @@ -529,7 +531,7 @@ export default class AppDeploy extends Command { // 10. Execute the deployment (on-chain stage). The image is already // built+pushed at this point; a failure here is distinct from a build // failure and a re-run will reuse the pushed image. - let res; + let res: Awaited>; try { res = await compute.app.executeDeploy(prepared, finalTx); } catch (err) { diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 4267cd43..46006973 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -5,6 +5,7 @@ import { isMainnet, WatchTimeoutError, } from "@layr-labs/ecloud-sdk"; +import type { PrepareUpgradeResult, GasEstimate } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; @@ -418,7 +419,8 @@ export default class AppUpgrade extends Command { // the image doesn't already have it. // Build/push stage — failures here mean no image was produced and no // on-chain tx was attempted. - let prepared, gasEstimate; + let prepared: PrepareUpgradeResult["prepared"]; + let gasEstimate: GasEstimate; try { ({ prepared, gasEstimate } = verifiableMode === "git" @@ -469,7 +471,7 @@ export default class AppUpgrade extends Command { // 11. Execute the upgrade (on-chain stage). Image already built+pushed; // a failure here is distinct from a build failure and a re-run reuses the // pushed image. - let res; + let res: Awaited>; try { res = await compute.app.executeUpgrade(prepared, finalTx); } catch (err) { From b20f026a591654ecab91de5cb5739e9afb2b5bab Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 12:01:23 -0300 Subject: [PATCH 49/55] refactor(cli): inject non-interactive decision into prompt helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional-input helpers (env-file, log-visibility, resource-usage- monitoring, dockerfile) called isNonInteractive() internally, re-deriving the decision from process.stdin/CI on every call. That dropped the --non-interactive flag: the bare call only sees CI + !isTTY, so --non-interactive on a real TTY (CI unset) still prompted. Resolve isNonInteractive(flags) once at the command boundary in deploy/ upgrade and thread the boolean into each helper as a parameter — matching the existing getInstanceTypeInteractive(..., nonInteractive) shape. No global state: helpers are now pure and unit-testable by passing the bool (no process.stdin mocking), and --non-interactive is honored everywhere. Tests: added a block asserting each helper honors the injected decision on a TTY (the dropped-flag case), plus a sanity check that nonInteractive=false still reaches the prompt. 104 CLI tests pass; eslint + prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 18 +++- .../cli/src/commands/compute/app/upgrade.ts | 18 +++- .../cli/src/utils/__tests__/prompts.test.ts | 97 +++++++++++++++++-- packages/cli/src/utils/prompts.ts | 20 ++-- 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 4fe9430e..06caa038 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -170,9 +170,15 @@ export default class AppDeploy extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(AppDeploy); + // Resolve the interactivity decision once (flag › CI › !TTY) and thread it + // into the optional-input helpers. They take it as a parameter rather than + // re-deriving from process internally, so --non-interactive is honored + // even on a TTY and the helpers stay pure/testable. + const nonInteractive = isNonInteractive(flags); + // Non-interactive: report every missing required input at once instead of // failing one prompt at a time. - if (isNonInteractive(flags)) { + if (nonInteractive) { const missing = collectMissingRequiredInputs( { imageRef: flags["image-ref"], @@ -322,7 +328,7 @@ export default class AppDeploy extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"]); + envFilePath = await getEnvFileInteractive(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { @@ -409,7 +415,7 @@ export default class AppDeploy extends Command { const dockerfilePath = isVerifiable || deployExistingImageRef ? "" - : await getDockerfileInteractive(flags.dockerfile); + : await getDockerfileInteractive(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 2. Get image reference interactively (context-aware) @@ -428,7 +434,7 @@ export default class AppDeploy extends Command { ); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"])); + envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -446,17 +452,19 @@ export default class AppDeploy extends Command { flags["instance-type"], "", // No pinned default for new deployments; non-interactive falls back to g1-standard-2s availableTypes, - isNonInteractive(flags), + nonInteractive, ); // 6. Get log visibility interactively const logSettings = await getLogSettingsInteractive( flags["log-visibility"] as LogVisibility | undefined, + nonInteractive, ); // 7. Get resource usage monitoring interactively const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + nonInteractive, ); // 8. Prepare deployment (builds image, pushes to registry, prepares batch, estimates gas) diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 46006973..2fd9cbfe 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -157,9 +157,15 @@ export default class AppUpgrade extends Command { // (oclif Args don't support env bindings directly). const appIdInput = args["app-id"] ?? process.env.ECLOUD_APP_ID; + // Resolve the interactivity decision once (flag › CI › !TTY) and thread it + // into the optional-input helpers. They take it as a parameter rather than + // re-deriving from process internally, so --non-interactive is honored + // even on a TTY and the helpers stay pure/testable. + const nonInteractive = isNonInteractive(flags); + // Non-interactive: report every missing required input at once instead of // failing one prompt at a time. - if (isNonInteractive(flags)) { + if (nonInteractive) { const missing = collectMissingRequiredInputs( { imageRef: flags["image-ref"], @@ -264,7 +270,7 @@ export default class AppUpgrade extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"]); + envFilePath = await getEnvFileInteractive(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { inputs.caddyfilePath = "Caddyfile"; @@ -346,7 +352,7 @@ export default class AppUpgrade extends Command { const dockerfilePath = isVerifiable || deployExistingImageRef ? "" - : await getDockerfileInteractive(flags.dockerfile); + : await getDockerfileInteractive(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 3. Get image reference interactively (context-aware) @@ -355,7 +361,7 @@ export default class AppUpgrade extends Command { : await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"])); + envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -392,17 +398,19 @@ export default class AppUpgrade extends Command { flags["instance-type"], currentInstanceType, availableTypes, - isNonInteractive(flags), + nonInteractive, ); // 7. Get log visibility interactively const logSettings = await getLogSettingsInteractive( flags["log-visibility"] as LogVisibility | undefined, + nonInteractive, ); // 8. Get resource usage monitoring interactively const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + nonInteractive, ); // 9. Prepare upgrade (builds image, pushes to registry, prepares batch, estimates gas) diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index df78856e..54ba63a1 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -1,5 +1,24 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "fs"; + +// Any helper that falls through to an interactive prompt is a bug in these +// tests' scenarios (we always run with a non-interactive intent). Make that +// fail loudly and deterministically instead of hanging on real stdin. +vi.mock("@inquirer/prompts", () => ({ + select: vi.fn(async () => { + throw new Error("unexpected interactive select()"); + }), + input: vi.fn(async () => { + throw new Error("unexpected interactive input()"); + }), + password: vi.fn(async () => { + throw new Error("unexpected interactive password()"); + }), + confirm: vi.fn(async () => { + throw new Error("unexpected interactive confirm()"); + }), +})); + import { getEnvironmentInteractive, promptUseVerifiableBuild, @@ -117,7 +136,7 @@ describe("non-interactive flag defaulting", () => { vi.spyOn(fs, "existsSync").mockReturnValue(false); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getEnvFileInteractive(undefined)).resolves.toBe(""); + await expect(getEnvFileInteractive(undefined, true)).resolves.toBe(""); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); }); @@ -133,7 +152,7 @@ describe("non-interactive flag defaulting", () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getLogSettingsInteractive(undefined)).resolves.toEqual({ + await expect(getLogSettingsInteractive(undefined, true)).resolves.toEqual({ logRedirect: "always", publicLogs: false, }); @@ -143,7 +162,7 @@ describe("non-interactive flag defaulting", () => { it("never silently defaults to public in non-TTY mode", async () => { process.stdin.isTTY = false; vi.spyOn(console, "warn").mockImplementation(() => {}); - const settings = await getLogSettingsInteractive(undefined); + const settings = await getLogSettingsInteractive(undefined, true); expect(settings.publicLogs).toBe(false); }); @@ -161,7 +180,7 @@ describe("non-interactive flag defaulting", () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getResourceUsageMonitoringInteractive(undefined)).resolves.toBe("disable"); + await expect(getResourceUsageMonitoringInteractive(undefined, true)).resolves.toBe("disable"); expect(warn).toHaveBeenCalledWith( expect.stringMatching(/--resource-usage-monitoring.*disable/), ); @@ -185,7 +204,7 @@ describe("non-interactive flag defaulting", () => { vi.spyOn(fs, "existsSync").mockReturnValue(true); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const result = await getDockerfileInteractive(undefined); + const result = await getDockerfileInteractive(undefined, true); expect(result).toMatch(/Dockerfile$/); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); }); @@ -244,6 +263,72 @@ describe("non-interactive flag defaulting", () => { }); }); +/** + * The optional-input helpers must honor an injected non-interactive decision, + * not re-derive it from process.stdin/CI internally. This is what lets + * `--non-interactive` work on a real TTY (where isTTY is true and CI is unset): + * the command resolves isNonInteractive(flags) once and threads the boolean + * down. Each test below runs on a TTY with CI unset — so a helper that ignores + * the injected flag would fall through to a (mocked, throwing) prompt. + */ +describe("optional-input helpers honor injected nonInteractive on a TTY", () => { + const origIsTTY = process.stdin.isTTY; + const origCI = process.env.CI; + + beforeEach(() => { + process.stdin.isTTY = true; + delete process.env.CI; + }); + afterEach(() => { + process.stdin.isTTY = origIsTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + vi.restoreAllMocks(); + }); + + it("getEnvFileInteractive defaults to no env file", async () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getEnvFileInteractive(undefined, true)).resolves.toBe(""); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); + }); + + it("getLogSettingsInteractive defaults to private logs", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getLogSettingsInteractive(undefined, true)).resolves.toEqual({ + logRedirect: "always", + publicLogs: false, + }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--log-visibility.*private/)); + }); + + it("getResourceUsageMonitoringInteractive defaults to disable", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getResourceUsageMonitoringInteractive(undefined, true)).resolves.toBe("disable"); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/--resource-usage-monitoring.*disable/), + ); + }); + + it("getDockerfileInteractive defaults to building the discovered Dockerfile", async () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await getDockerfileInteractive(undefined, true); + expect(result).toMatch(/Dockerfile$/); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); + }); + + it("the same helpers still prompt on a TTY when nonInteractive is false", async () => { + // Sanity: when the injected decision is interactive, the helper reaches the + // (mocked) prompt rather than silently defaulting. Proves the boolean is + // actually driving the branch, not being ignored. + vi.spyOn(fs, "existsSync").mockReturnValue(false); + await expect(getLogSettingsInteractive(undefined, false)).rejects.toThrow( + /unexpected interactive/, + ); + }); +}); + describe("isNonInteractive detection", () => { const origTTY = process.stdin.isTTY; const origCI = process.env.CI; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 82737fd9..bf86ca40 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -135,7 +135,10 @@ export function collectMissingRequiredInputs( /** * Prompt for Dockerfile selection */ -export async function getDockerfileInteractive(dockerfilePath?: string): Promise { +export async function getDockerfileInteractive( + dockerfilePath?: string, + nonInteractive: boolean = false, +): Promise { // Check if provided via option if (dockerfilePath) { return dockerfilePath; @@ -157,7 +160,7 @@ export async function getDockerfileInteractive(dockerfilePath?: string): Promise // sitting in the working directory. Callers that pass --image-ref skip this // helper entirely (see deploy.ts / upgrade.ts), so this default never // overrides an explicit image reference. - if (isNonInteractive()) { + if (nonInteractive) { warnDefaulted("--dockerfile/--image-ref", `build from '${dockerfilePath_resolved}'`); return dockerfilePath_resolved; } @@ -890,7 +893,10 @@ export async function promptBuildIdFromRecentBuilds(options: { /** * Prompt for environment file */ -export async function getEnvFileInteractive(envFilePath?: string): Promise { +export async function getEnvFileInteractive( + envFilePath?: string, + nonInteractive: boolean = false, +): Promise { if (envFilePath && fs.existsSync(envFilePath)) { return envFilePath; } @@ -901,7 +907,7 @@ export async function getEnvFileInteractive(envFilePath?: string): Promise { if (logVisibility) { switch (logVisibility) { @@ -1084,7 +1091,7 @@ export async function getLogSettingsInteractive( // In non-interactive mode, default to private logs. Never silently default // to public — private is the conservative choice for an unattended deploy. - if (isNonInteractive()) { + if (nonInteractive) { warnDefaulted("--log-visibility", "'private'"); return { logRedirect: "always", publicLogs: false }; } @@ -1546,6 +1553,7 @@ export type ResourceUsageMonitoring = "enable" | "disable"; */ export async function getResourceUsageMonitoringInteractive( resourceUsageMonitoring?: ResourceUsageMonitoring, + nonInteractive: boolean = false, ): Promise { if (resourceUsageMonitoring) { switch (resourceUsageMonitoring) { @@ -1560,7 +1568,7 @@ export async function getResourceUsageMonitoringInteractive( } // In non-interactive mode, default to disabled resource usage monitoring. - if (isNonInteractive()) { + if (nonInteractive) { warnDefaulted("--resource-usage-monitoring", "'disable'"); return "disable"; } From fdcd0f735d74e2d1a3d5f8da33cb5456f6574a77 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 12:08:56 -0300 Subject: [PATCH 50/55] fix(cli): surface wallet-ETH read failure in billing status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-ETH balance read in `billing status` was wrapped in an empty catch that discarded the error. It lumped three distinct failures — malformed --private-key, bad environment config, and transient RPC errors — into a silent no-op: the user saw no ETH line and no reason why. Keep it best-effort (must not abort `billing status`) but warn with the reason via this.warn, matching the existing pattern in app list/info. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commands/billing/__tests__/status.test.ts | 57 ++++++++++++++++++- packages/cli/src/commands/billing/status.ts | 9 ++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/billing/__tests__/status.test.ts b/packages/cli/src/commands/billing/__tests__/status.test.ts index 70b76c47..e2bb0d24 100644 --- a/packages/cli/src/commands/billing/__tests__/status.test.ts +++ b/packages/cli/src/commands/billing/__tests__/status.test.ts @@ -8,7 +8,16 @@ vi.mock("../../../telemetry", () => ({ withTelemetry: vi.fn((_cmd: unknown, fn: () => Promise) => fn()), })); +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getEnvironmentConfig: vi.fn(() => ({ defaultRPCURL: "https://rpc.example" })), +})); + +vi.mock("../../../utils/viemClients", () => ({ + createViemClients: vi.fn(), +})); + import { createBillingClient } from "../../../client"; +import { createViemClients } from "../../../utils/viemClients"; describe("ecloud billing status — top-up hint", () => { let logOutput: string[]; @@ -19,6 +28,7 @@ describe("ecloud billing status — top-up hint", () => { beforeEach(() => { logOutput = []; + warnOutput = []; mockBilling = { address: "0xabcdef1234567890abcdef1234567890abcdef12", getStatus: vi.fn(), @@ -26,15 +36,24 @@ describe("ecloud billing status — top-up hint", () => { (createBillingClient as ReturnType).mockResolvedValue(mockBilling); }); - async function runStatusCommand(statusResult: Record) { + let warnOutput: string[]; + + async function runStatusCommand( + statusResult: Record, + flagOverrides: Record = {}, + ) { const { default: BillingStatus } = await import("../status"); mockBilling.getStatus.mockResolvedValue(statusResult); const cmd = new BillingStatus([], {} as any); cmd.parse = vi.fn().mockResolvedValue({ - flags: { product: "compute", verbose: false }, + flags: { product: "compute", verbose: false, ...flagOverrides }, }); cmd.log = vi.fn((...args: string[]) => logOutput.push(args.join(" "))); + cmd.warn = vi.fn((msg: string | Error) => { + warnOutput.push(typeof msg === "string" ? msg : msg.message); + return msg as string & Error; + }); cmd.debug = vi.fn(); await cmd.run(); @@ -84,4 +103,38 @@ describe("ecloud billing status — top-up hint", () => { expect(fullOutput).not.toContain("Need more credits?"); }); + + describe("wallet ETH balance line", () => { + it("warns (does not silently swallow) when the balance read fails, but still completes", async () => { + (createViemClients as ReturnType).mockImplementation(() => { + throw new Error("invalid private key"); + }); + + const output = await runStatusCommand( + { subscriptionStatus: "active", productId: "compute" }, + { "private-key": "0xbad", environment: "sepolia" }, + ); + + // The command still finishes and prints the rest of the status. + expect(output.join("\n")).toContain("Subscription Status:"); + // The failure reason is surfaced, not discarded. + expect(warnOutput.join("\n")).toMatch(/wallet ETH|balance/i); + expect(warnOutput.join("\n")).toContain("invalid private key"); + }); + + it("prints the ETH line on a successful balance read", async () => { + (createViemClients as ReturnType).mockReturnValue({ + publicClient: { getBalance: vi.fn().mockResolvedValue(1000000000000000000n) }, + address: "0xabcdef1234567890abcdef1234567890abcdef12", + }); + + const output = await runStatusCommand( + { subscriptionStatus: "active", productId: "compute" }, + { "private-key": "0xgood", environment: "sepolia" }, + ); + + expect(output.join("\n")).toMatch(/Wallet ETH.*1 ETH/); + expect(warnOutput).toHaveLength(0); + }); + }); }); diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index 4d6f1f3a..992368ec 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -3,6 +3,7 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { getEnvironmentConfig } from "@layr-labs/ecloud-sdk"; import { createViemClients } from "../../utils/viemClients"; +import { errorMessage } from "../../utils/exitCodes"; import { formatEther } from "viem"; import chalk from "chalk"; import { withTelemetry } from "../../telemetry"; @@ -80,8 +81,12 @@ export default class BillingStatus extends Command { : ""; this.log(` Wallet ETH (${environment}): ${chalk.cyan(`${eth} ETH`)}${note}`); } - } catch { - // ignore — ETH balance is informational + } 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)}`); From 4f95fa46af0fea0acc61d311464db423342f2063 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 12:48:13 -0300 Subject: [PATCH 51/55] refactor(cli): drop "Interactive" suffix from dual-mode prompt helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDockerfileInteractive / getEnvFileInteractive / getLogSettingsInteractive / getResourceUsageMonitoringInteractive / getInstanceTypeInteractive each take a nonInteractive argument now, so the "Interactive" suffix read contradictorily (getDockerfileInteractive(..., nonInteractive)). These helpers resolve a value from flag/default/prompt in either mode, so rename to getDockerfile / getEnvFile / getLogSettings / getResourceUsageMonitoring / getInstanceType and update their doc comments. The genuinely interactive-only helpers (getImageReferenceInteractive, getEnvironmentInteractive, etc.) keep their suffix. Also clarify exitCodes.ts: exit 1 is oclif's default for unclassified errors, not one we define — the doc comment listed it alongside our 2/3/4. No behavior change; 106 CLI tests pass; eslint + prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 22 +++--- .../cli/src/commands/compute/app/upgrade.ts | 22 +++--- .../cli/src/utils/__tests__/prompts.test.ts | 78 +++++++++---------- packages/cli/src/utils/exitCodes.ts | 5 +- packages/cli/src/utils/prompts.ts | 22 +++--- 5 files changed, 74 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 06caa038..3664977b 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -11,14 +11,14 @@ import { commonFlags, applyTxOverrides } from "../../../flags"; import { createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; import { - getDockerfileInteractive, + getDockerfile, getImageReferenceInteractive, getOrPromptAppName, - getEnvFileInteractive, - getInstanceTypeInteractive, + getEnvFile, + getInstanceType, type SkuInfo, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, + getLogSettings, + getResourceUsageMonitoring, getAppProfileInteractive, LogVisibility, ResourceUsageMonitoring, @@ -328,7 +328,7 @@ export default class AppDeploy extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"], nonInteractive); + envFilePath = await getEnvFile(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { @@ -415,7 +415,7 @@ export default class AppDeploy extends Command { const dockerfilePath = isVerifiable || deployExistingImageRef ? "" - : await getDockerfileInteractive(flags.dockerfile, nonInteractive); + : await getDockerfile(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 2. Get image reference interactively (context-aware) @@ -434,7 +434,7 @@ export default class AppDeploy extends Command { ); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"], nonInteractive)); + envFilePath = envFilePath ?? (await getEnvFile(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -448,7 +448,7 @@ export default class AppDeploy extends Command { privateKey, rpcUrl, ); - const instanceType = await getInstanceTypeInteractive( + const instanceType = await getInstanceType( flags["instance-type"], "", // No pinned default for new deployments; non-interactive falls back to g1-standard-2s availableTypes, @@ -456,13 +456,13 @@ export default class AppDeploy extends Command { ); // 6. Get log visibility interactively - const logSettings = await getLogSettingsInteractive( + const logSettings = await getLogSettings( flags["log-visibility"] as LogVisibility | undefined, nonInteractive, ); // 7. Get resource usage monitoring interactively - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + const resourceUsageMonitoring = await getResourceUsageMonitoring( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, nonInteractive, ); diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 2fd9cbfe..8da7c27a 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -11,13 +11,13 @@ import { commonFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; import { - getDockerfileInteractive, + getDockerfile, getImageReferenceInteractive, - getEnvFileInteractive, - getInstanceTypeInteractive, + getEnvFile, + getInstanceType, type SkuInfo, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, + getLogSettings, + getResourceUsageMonitoring, getOrPromptAppID, LogVisibility, ResourceUsageMonitoring, @@ -270,7 +270,7 @@ export default class AppUpgrade extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"], nonInteractive); + envFilePath = await getEnvFile(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { inputs.caddyfilePath = "Caddyfile"; @@ -352,7 +352,7 @@ export default class AppUpgrade extends Command { const dockerfilePath = isVerifiable || deployExistingImageRef ? "" - : await getDockerfileInteractive(flags.dockerfile, nonInteractive); + : await getDockerfile(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 3. Get image reference interactively (context-aware) @@ -361,7 +361,7 @@ export default class AppUpgrade extends Command { : await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"], nonInteractive)); + envFilePath = envFilePath ?? (await getEnvFile(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -394,7 +394,7 @@ export default class AppUpgrade extends Command { privateKey, rpcUrl, ); - const instanceType = await getInstanceTypeInteractive( + const instanceType = await getInstanceType( flags["instance-type"], currentInstanceType, availableTypes, @@ -402,13 +402,13 @@ export default class AppUpgrade extends Command { ); // 7. Get log visibility interactively - const logSettings = await getLogSettingsInteractive( + const logSettings = await getLogSettings( flags["log-visibility"] as LogVisibility | undefined, nonInteractive, ); // 8. Get resource usage monitoring interactively - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + const resourceUsageMonitoring = await getResourceUsageMonitoring( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, nonInteractive, ); diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index 54ba63a1..b28dd73d 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -22,11 +22,11 @@ vi.mock("@inquirer/prompts", () => ({ import { getEnvironmentInteractive, promptUseVerifiableBuild, - getDockerfileInteractive, - getEnvFileInteractive, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, - getInstanceTypeInteractive, + getDockerfile, + getEnvFile, + getLogSettings, + getResourceUsageMonitoring, + getInstanceType, isNonInteractive, collectMissingRequiredInputs, } from "../prompts"; @@ -129,30 +129,30 @@ describe("non-interactive flag defaulting", () => { vi.restoreAllMocks(); }); - describe("getEnvFileInteractive", () => { + describe("getEnvFile", () => { it("defaults to no env file in non-TTY mode when none is found", async () => { process.stdin.isTTY = false; // No explicit path, and no auto-detected .env on disk. vi.spyOn(fs, "existsSync").mockReturnValue(false); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getEnvFileInteractive(undefined, true)).resolves.toBe(""); + await expect(getEnvFile(undefined, true)).resolves.toBe(""); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); }); it("still returns an explicitly provided, existing env file in non-TTY mode", async () => { process.stdin.isTTY = false; vi.spyOn(fs, "existsSync").mockImplementation((p) => p === "custom.env"); - await expect(getEnvFileInteractive("custom.env")).resolves.toBe("custom.env"); + await expect(getEnvFile("custom.env")).resolves.toBe("custom.env"); }); }); - describe("getLogSettingsInteractive", () => { + describe("getLogSettings", () => { it("defaults to private logs in non-TTY mode", async () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getLogSettingsInteractive(undefined, true)).resolves.toEqual({ + await expect(getLogSettings(undefined, true)).resolves.toEqual({ logRedirect: "always", publicLogs: false, }); @@ -162,25 +162,25 @@ describe("non-interactive flag defaulting", () => { it("never silently defaults to public in non-TTY mode", async () => { process.stdin.isTTY = false; vi.spyOn(console, "warn").mockImplementation(() => {}); - const settings = await getLogSettingsInteractive(undefined, true); + const settings = await getLogSettings(undefined, true); expect(settings.publicLogs).toBe(false); }); it("honors an explicit --log-visibility value regardless of TTY", async () => { process.stdin.isTTY = false; - await expect(getLogSettingsInteractive("public")).resolves.toEqual({ + await expect(getLogSettings("public")).resolves.toEqual({ logRedirect: "always", publicLogs: true, }); }); }); - describe("getResourceUsageMonitoringInteractive", () => { + describe("getResourceUsageMonitoring", () => { it("defaults to disable in non-TTY mode", async () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getResourceUsageMonitoringInteractive(undefined, true)).resolves.toBe("disable"); + await expect(getResourceUsageMonitoring(undefined, true)).resolves.toBe("disable"); expect(warn).toHaveBeenCalledWith( expect.stringMatching(/--resource-usage-monitoring.*disable/), ); @@ -188,15 +188,15 @@ describe("non-interactive flag defaulting", () => { it("honors an explicit value regardless of TTY", async () => { process.stdin.isTTY = false; - await expect(getResourceUsageMonitoringInteractive("enable")).resolves.toBe("enable"); + await expect(getResourceUsageMonitoring("enable")).resolves.toBe("enable"); }); }); - describe("getDockerfileInteractive", () => { + describe("getDockerfile", () => { it("returns '' (deploy existing image) when no Dockerfile exists, even in non-TTY mode", async () => { process.stdin.isTTY = false; vi.spyOn(fs, "existsSync").mockReturnValue(false); - await expect(getDockerfileInteractive(undefined)).resolves.toBe(""); + await expect(getDockerfile(undefined)).resolves.toBe(""); }); it("defaults to building the discovered Dockerfile in non-TTY mode", async () => { @@ -204,20 +204,18 @@ describe("non-interactive flag defaulting", () => { vi.spyOn(fs, "existsSync").mockReturnValue(true); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const result = await getDockerfileInteractive(undefined, true); + const result = await getDockerfile(undefined, true); expect(result).toMatch(/Dockerfile$/); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); }); it("returns an explicitly provided Dockerfile path verbatim", async () => { process.stdin.isTTY = false; - await expect(getDockerfileInteractive("./custom/Dockerfile")).resolves.toBe( - "./custom/Dockerfile", - ); + await expect(getDockerfile("./custom/Dockerfile")).resolves.toBe("./custom/Dockerfile"); }); }); - describe("getInstanceTypeInteractive", () => { + describe("getInstanceType", () => { const types = [ { sku: "g1-standard-2s", friendly_name: "Standard 2s", description: "2 vCPU, SEV-SNP" }, { sku: "g1-standard-4t", friendly_name: "Standard 4t", description: "4 vCPU, TDX" }, @@ -225,18 +223,14 @@ describe("non-interactive flag defaulting", () => { it("returns an explicitly provided, valid instance type in non-TTY mode", async () => { process.stdin.isTTY = false; - await expect(getInstanceTypeInteractive("g1-standard-2s", "", types)).resolves.toBe( - "g1-standard-2s", - ); + await expect(getInstanceType("g1-standard-2s", "", types)).resolves.toBe("g1-standard-2s"); }); // Deploy with no instance type defaults to g1-standard-2s in non-interactive mode. it("defaults to g1-standard-2s in non-interactive deploy when available", async () => { process.stdin.isTTY = false; const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getInstanceTypeInteractive(undefined, "", types, true)).resolves.toBe( - "g1-standard-2s", - ); + await expect(getInstanceType(undefined, "", types, true)).resolves.toBe("g1-standard-2s"); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--instance-type.*g1-standard-2s/)); }); @@ -244,15 +238,15 @@ describe("non-interactive flag defaulting", () => { it("reuses defaultSKU (pinned type) in non-interactive upgrade", async () => { process.stdin.isTTY = false; vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect( - getInstanceTypeInteractive(undefined, "g1-standard-4t", types, true), - ).resolves.toBe("g1-standard-4t"); + await expect(getInstanceType(undefined, "g1-standard-4t", types, true)).resolves.toBe( + "g1-standard-4t", + ); }); it("errors in non-interactive when the default SKU is not offered", async () => { process.stdin.isTTY = false; await expect( - getInstanceTypeInteractive( + getInstanceType( undefined, "", [{ sku: "g1-micro-1v", friendly_name: "m", description: "" }], @@ -286,34 +280,34 @@ describe("optional-input helpers honor injected nonInteractive on a TTY", () => vi.restoreAllMocks(); }); - it("getEnvFileInteractive defaults to no env file", async () => { + it("getEnvFile defaults to no env file", async () => { vi.spyOn(fs, "existsSync").mockReturnValue(false); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getEnvFileInteractive(undefined, true)).resolves.toBe(""); + await expect(getEnvFile(undefined, true)).resolves.toBe(""); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); }); - it("getLogSettingsInteractive defaults to private logs", async () => { + it("getLogSettings defaults to private logs", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getLogSettingsInteractive(undefined, true)).resolves.toEqual({ + await expect(getLogSettings(undefined, true)).resolves.toEqual({ logRedirect: "always", publicLogs: false, }); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--log-visibility.*private/)); }); - it("getResourceUsageMonitoringInteractive defaults to disable", async () => { + it("getResourceUsageMonitoring defaults to disable", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(getResourceUsageMonitoringInteractive(undefined, true)).resolves.toBe("disable"); + await expect(getResourceUsageMonitoring(undefined, true)).resolves.toBe("disable"); expect(warn).toHaveBeenCalledWith( expect.stringMatching(/--resource-usage-monitoring.*disable/), ); }); - it("getDockerfileInteractive defaults to building the discovered Dockerfile", async () => { + it("getDockerfile defaults to building the discovered Dockerfile", async () => { vi.spyOn(fs, "existsSync").mockReturnValue(true); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const result = await getDockerfileInteractive(undefined, true); + const result = await getDockerfile(undefined, true); expect(result).toMatch(/Dockerfile$/); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); }); @@ -323,9 +317,7 @@ describe("optional-input helpers honor injected nonInteractive on a TTY", () => // (mocked) prompt rather than silently defaulting. Proves the boolean is // actually driving the branch, not being ignored. vi.spyOn(fs, "existsSync").mockReturnValue(false); - await expect(getLogSettingsInteractive(undefined, false)).rejects.toThrow( - /unexpected interactive/, - ); + await expect(getLogSettings(undefined, false)).rejects.toThrow(/unexpected interactive/); }); }); diff --git a/packages/cli/src/utils/exitCodes.ts b/packages/cli/src/utils/exitCodes.ts index 3d1ca80c..79374a9d 100644 --- a/packages/cli/src/utils/exitCodes.ts +++ b/packages/cli/src/utils/exitCodes.ts @@ -5,7 +5,10 @@ * A ~7-minute build that succeeds and then fails on-chain must be * distinguishable from a build that never produced an image. * - * 1 generic / unclassified error (oclif default) + * Exit code 1 is intentionally NOT defined here: it is oclif's default for any + * unclassified error (a plain `this.error(msg)` with no `exit` option). The + * codes below are the stage-specific ones we assign on top of that baseline. + * * 2 invalid or missing input — fails before any build * 3 build/push failed — no on-chain transaction was attempted * 4 build/push succeeded but the on-chain transaction failed diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index bf86ca40..58e01089 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -133,9 +133,10 @@ export function collectMissingRequiredInputs( // ==================== Dockerfile Selection ==================== /** - * Prompt for Dockerfile selection + * Resolve the Dockerfile path: explicit flag, else the discovered ./Dockerfile. + * When nonInteractive, defaults instead of prompting for build-vs-existing. */ -export async function getDockerfileInteractive( +export async function getDockerfile( dockerfilePath?: string, nonInteractive: boolean = false, ): Promise { @@ -891,9 +892,10 @@ export async function promptBuildIdFromRecentBuilds(options: { // ==================== Environment File Selection ==================== /** - * Prompt for environment file + * Resolve the env file path: explicit flag, else auto-detected ./.env. + * When nonInteractive, defaults to no env file instead of prompting. */ -export async function getEnvFileInteractive( +export async function getEnvFile( envFilePath?: string, nonInteractive: boolean = false, ): Promise { @@ -991,7 +993,7 @@ function formatSkuChoice(it: SkuInfo): string { return `${it.sku} - ${it.description}`; } -export async function getInstanceTypeInteractive( +export async function getInstanceType( instanceType: string | undefined, defaultSKU: string, availableTypes: SkuInfo[], @@ -1068,9 +1070,10 @@ export async function getInstanceTypeInteractive( export type LogVisibility = "public" | "private" | "off"; /** - * Prompt for log settings + * Resolve log settings from the --log-visibility value. + * When nonInteractive, defaults to private instead of prompting. */ -export async function getLogSettingsInteractive( +export async function getLogSettings( logVisibility?: LogVisibility, nonInteractive: boolean = false, ): Promise<{ logRedirect: string; publicLogs: boolean }> { @@ -1549,9 +1552,10 @@ async function getAppIDInteractiveFromRegistry( export type ResourceUsageMonitoring = "enable" | "disable"; /** - * Prompt for resource usage monitoring settings + * Resolve the resource-usage-monitoring setting from the flag value. + * When nonInteractive, defaults to disable instead of prompting. */ -export async function getResourceUsageMonitoringInteractive( +export async function getResourceUsageMonitoring( resourceUsageMonitoring?: ResourceUsageMonitoring, nonInteractive: boolean = false, ): Promise { From 7323563abd9cb5d12a568e413b999ef3d6443fcb Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 13:33:41 -0300 Subject: [PATCH 52/55] refactor(cli): extract stageFailure() to map deploy/upgrade stage -> exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old exitCodes test only asserted the EXIT_CODES constants and a trivial errorMessage helper — it would pass even if the codes were never wired into the commands. And the six this.error(..., { exit }) blocks (3 stages x deploy /upgrade) duplicated the message + code mapping inline. Extract a pure stageFailure(operation, stage, err) -> { message, exit } that owns the mapping, and call it from both commands. Now the real logic is unit- tested directly (invalid-input -> 2, build -> 3 "no X was attempted", onchain -> 4 "image already pushed, re-run reuses it"), with operation-specific wording, and the duplication is gone. 110 CLI tests pass; eslint + prettier clean; no new tsc errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 20 +++---- .../cli/src/commands/compute/app/upgrade.ts | 20 +++---- .../cli/src/utils/__tests__/exitCodes.test.ts | 55 +++++++++++++++---- packages/cli/src/utils/exitCodes.ts | 39 +++++++++++++ 4 files changed, 100 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 3664977b..3bbc31cb 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -44,7 +44,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; -import { EXIT_CODES, errorMessage } from "../../../utils/exitCodes"; +import { stageFailure } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppDeploy extends Command { @@ -191,10 +191,12 @@ export default class AppDeploy extends Command { "name", ); if (missing.length > 0) { - this.error( + const { message, exit } = stageFailure( + "deploy", + "invalid-input", `Missing required input(s) for non-interactive deploy:\n - ${missing.join("\n - ")}`, - { exit: EXIT_CODES.INVALID_INPUT }, ); + this.error(message, { exit }); } } @@ -509,9 +511,8 @@ export default class AppDeploy extends Command { billTo: "developer", })); } catch (err) { - this.error(`Build/push failed (no deployment was attempted): ${errorMessage(err)}`, { - exit: EXIT_CODES.BUILD_FAILED, - }); + const { message, exit } = stageFailure("deploy", "build", err); + this.error(message, { exit }); } // 9. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet @@ -543,11 +544,8 @@ export default class AppDeploy extends Command { try { res = await compute.app.executeDeploy(prepared, finalTx); } catch (err) { - this.error( - `On-chain deployment failed after the image was built and pushed: ${errorMessage(err)}\n` + - `The image is already pushed — re-running deploy will reuse it.`, - { exit: EXIT_CODES.ONCHAIN_FAILED }, - ); + const { message, exit } = stageFailure("deploy", "onchain", err); + this.error(message, { exit }); } // 11. Collect app profile while deployment is in progress (optional) diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 8da7c27a..2be650d1 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -41,7 +41,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; -import { EXIT_CODES, errorMessage } from "../../../utils/exitCodes"; +import { stageFailure } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppUpgrade extends Command { @@ -180,10 +180,12 @@ export default class AppUpgrade extends Command { missing.push("app-id (positional arg or ECLOUD_APP_ID)"); } if (missing.length > 0) { - this.error( + const { message, exit } = stageFailure( + "upgrade", + "invalid-input", `Missing required input(s) for non-interactive upgrade:\n - ${missing.join("\n - ")}`, - { exit: EXIT_CODES.INVALID_INPUT }, ); + this.error(message, { exit }); } } @@ -449,9 +451,8 @@ export default class AppUpgrade extends Command { resourceUsageMonitoring, })); } catch (err) { - this.error(`Build/push failed (no upgrade was attempted): ${errorMessage(err)}`, { - exit: EXIT_CODES.BUILD_FAILED, - }); + const { message, exit } = stageFailure("upgrade", "build", err); + this.error(message, { exit }); } // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet @@ -483,11 +484,8 @@ export default class AppUpgrade extends Command { try { res = await compute.app.executeUpgrade(prepared, finalTx); } catch (err) { - this.error( - `On-chain upgrade failed after the image was built and pushed: ${errorMessage(err)}\n` + - `The image is already pushed — re-running upgrade will reuse it.`, - { exit: EXIT_CODES.ONCHAIN_FAILED }, - ); + const { message, exit } = stageFailure("upgrade", "onchain", err); + this.error(message, { exit }); } // 12. Watch until upgrade completes diff --git a/packages/cli/src/utils/__tests__/exitCodes.test.ts b/packages/cli/src/utils/__tests__/exitCodes.test.ts index 871a07f1..a0dbd108 100644 --- a/packages/cli/src/utils/__tests__/exitCodes.test.ts +++ b/packages/cli/src/utils/__tests__/exitCodes.test.ts @@ -1,21 +1,52 @@ import { describe, expect, it } from "vitest"; -import { EXIT_CODES, errorMessage } from "../exitCodes"; - -describe("deploy/upgrade exit codes", () => { - it("uses distinct, stable codes per failure stage", () => { - expect(EXIT_CODES.INVALID_INPUT).toBe(2); - expect(EXIT_CODES.BUILD_FAILED).toBe(3); - expect(EXIT_CODES.ONCHAIN_FAILED).toBe(4); - // All distinct. - expect(new Set(Object.values(EXIT_CODES)).size).toBe(3); - }); +import { EXIT_CODES, errorMessage, stageFailure } from "../exitCodes"; - it("errorMessage extracts Error.message", () => { +describe("errorMessage", () => { + it("extracts Error.message", () => { expect(errorMessage(new Error("boom"))).toBe("boom"); }); - it("errorMessage stringifies non-Error values", () => { + it("stringifies non-Error values", () => { expect(errorMessage("plain string")).toBe("plain string"); expect(errorMessage(42)).toBe("42"); }); }); + +describe("stageFailure — maps a failed deploy/upgrade stage to message + exit code", () => { + it("invalid-input maps to exit 2 before any build", () => { + const { exit } = stageFailure("deploy", "invalid-input", "two flags missing"); + expect(exit).toBe(EXIT_CODES.INVALID_INPUT); + }); + + it("build-stage failure maps to exit 3 and says no on-chain tx was attempted", () => { + const { message, exit } = stageFailure("deploy", "build", new Error("docker push 500")); + expect(exit).toBe(EXIT_CODES.BUILD_FAILED); + expect(message).toContain("Build/push failed"); + expect(message).toContain("no deployment was attempted"); + expect(message).toContain("docker push 500"); + }); + + it("on-chain-stage failure maps to exit 4 and notes the image is already pushed", () => { + const { message, exit } = stageFailure("deploy", "onchain", new Error("nonce too low")); + expect(exit).toBe(EXIT_CODES.ONCHAIN_FAILED); + expect(message).toContain("On-chain deployment failed"); + expect(message).toContain("nonce too low"); + expect(message).toContain("re-running deploy will reuse it"); + }); + + it("uses operation-specific wording for upgrade", () => { + const build = stageFailure("upgrade", "build", new Error("x")); + expect(build.message).toContain("no upgrade was attempted"); + + const onchain = stageFailure("upgrade", "onchain", new Error("x")); + expect(onchain.message).toContain("On-chain upgrade failed"); + expect(onchain.message).toContain("re-running upgrade will reuse it"); + }); + + it("each stage carries a distinct exit code", () => { + const exits = (["invalid-input", "build", "onchain"] as const).map( + (s) => stageFailure("deploy", s, "e").exit, + ); + expect(new Set(exits).size).toBe(3); + }); +}); diff --git a/packages/cli/src/utils/exitCodes.ts b/packages/cli/src/utils/exitCodes.ts index 79374a9d..b81ac7c0 100644 --- a/packages/cli/src/utils/exitCodes.ts +++ b/packages/cli/src/utils/exitCodes.ts @@ -27,3 +27,42 @@ export function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; return String(err); } + +/** The operation a failure occurred under (drives the user-facing wording). */ +export type DeployOperation = "deploy" | "upgrade"; + +/** Which stage of deploy/upgrade failed (drives the exit code). */ +export type DeployStage = "invalid-input" | "build" | "onchain"; + +/** + * Map a failed deploy/upgrade stage to a user-facing message and the matching + * process exit code, so a caller (CI, agent) keying off exit status can tell + * *which stage* failed. Pure — the command layer feeds the result straight to + * `this.error(message, { exit })`. + * + * - build: no image was produced, so no on-chain tx was attempted (exit 3). + * - onchain: the image is already built+pushed; a re-run reuses it (exit 4). + */ +export function stageFailure( + operation: DeployOperation, + stage: DeployStage, + err: unknown, +): { message: string; exit: DeployStageExitCode } { + const noun = operation === "deploy" ? "deployment" : "upgrade"; + switch (stage) { + case "invalid-input": + return { message: errorMessage(err), exit: EXIT_CODES.INVALID_INPUT }; + case "build": + return { + message: `Build/push failed (no ${noun} was attempted): ${errorMessage(err)}`, + exit: EXIT_CODES.BUILD_FAILED, + }; + case "onchain": + return { + message: + `On-chain ${noun} failed after the image was built and pushed: ${errorMessage(err)}\n` + + `The image is already pushed — re-running ${operation} will reuse it.`, + exit: EXIT_CODES.ONCHAIN_FAILED, + }; + } +} From 4e27c37efa493c9e5893c9d0a9513aaa727ca663 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Thu, 4 Jun 2026 14:17:52 -0300 Subject: [PATCH 53/55] fix(cli): correct exit-code classification for gas + dockerfile cases; dedup instance-type fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes surfaced by review: 1. Insufficient-gas failures were misclassified as exit 3 ("build failed, no deployment attempted"). assertSufficientGas runs inside prepare*() AFTER the image is built+pushed, so an InsufficientGasError surfaces through the build try/catch — but the image already exists. stageFailure now reclassifies InsufficientGasError as on-chain (exit 4, "re-run reuses the pushed image"), matching the documented invariant and giving agents an accurate signal. 2. Non-interactive deploy/upgrade with --dockerfile but no --image-ref slipped past the all-at-once required-input check, then threw at the interactive --image-ref prompt as an unclassified exit 1. collectMissingRequiredInputs now requires --image-ref (the push destination) when building from a local Dockerfile, so it's reported as invalid-input (exit 2) up front. Also dedup: fetchAvailableInstanceTypes was byte-identical in deploy.ts and upgrade.ts — extracted to utils/instanceTypes.ts. (This also removes one duplicate copy of a pre-existing SkuInfo fallback-shape tsc error: 43 -> 41.) 113 CLI tests pass; eslint + prettier clean; no new tsc errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/src/commands/compute/app/deploy.ts | 42 +------------------ .../cli/src/commands/compute/app/upgrade.ts | 34 +-------------- .../cli/src/utils/__tests__/exitCodes.test.ts | 17 ++++++++ .../cli/src/utils/__tests__/prompts.test.ts | 14 +++++++ packages/cli/src/utils/exitCodes.ts | 15 +++++++ packages/cli/src/utils/instanceTypes.ts | 40 ++++++++++++++++++ packages/cli/src/utils/prompts.ts | 5 +++ 7 files changed, 94 insertions(+), 73 deletions(-) create mode 100644 packages/cli/src/utils/instanceTypes.ts diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 3bbc31cb..c3507993 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -1,10 +1,5 @@ import { Command, Flags } from "@oclif/core"; -import { - getEnvironmentConfig, - UserApiClient, - isMainnet, - WatchTimeoutError, -} from "@layr-labs/ecloud-sdk"; +import { getEnvironmentConfig, isMainnet, WatchTimeoutError } from "@layr-labs/ecloud-sdk"; import type { PrepareDeployResult, GasEstimate } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; @@ -16,7 +11,6 @@ import { getOrPromptAppName, getEnvFile, getInstanceType, - type SkuInfo, getLogSettings, getResourceUsageMonitoring, getAppProfileInteractive, @@ -32,7 +26,7 @@ import { collectMissingRequiredInputs, } from "../../../utils/prompts"; import { invalidateProfileCache, setLinkedAppForDirectory } from "../../../utils/globalConfig"; -import { getClientId } from "../../../utils/version"; +import { fetchAvailableInstanceTypes } from "../../../utils/instanceTypes"; import chalk from "chalk"; import { createBuildClient } from "../../../client"; import { formatVerifiableBuildSummary } from "../../../utils/build"; @@ -673,35 +667,3 @@ export default class AppDeploy extends Command { }); } } - -/** - * Fetch available instance types from backend - */ -async function fetchAvailableInstanceTypes( - environment: string, - environmentConfig: any, - privateKey: string, - rpcUrl: string, -): Promise { - try { - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length === 0) { - throw new Error("No instance types available from server"); - } - - return skuList.skus; - } catch (err: any) { - console.warn(`Failed to fetch instance types: ${err.message}`); - // Return a default fallback - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; - } -} diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 2be650d1..7004e226 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -15,7 +15,6 @@ import { getImageReferenceInteractive, getEnvFile, getInstanceType, - type SkuInfo, getLogSettings, getResourceUsageMonitoring, getOrPromptAppID, @@ -30,6 +29,7 @@ import { collectMissingRequiredInputs, } from "../../../utils/prompts"; import { getClientId } from "../../../utils/version"; +import { fetchAvailableInstanceTypes } from "../../../utils/instanceTypes"; import { setLinkedAppForDirectory, invalidateProfileCache } from "../../../utils/globalConfig"; import chalk from "chalk"; import { formatVerifiableBuildSummary } from "../../../utils/build"; @@ -567,35 +567,3 @@ export default class AppUpgrade extends Command { }); } } - -/** - * Fetch available instance types from backend - */ -async function fetchAvailableInstanceTypes( - environment: string, - environmentConfig: any, - privateKey: string, - rpcUrl: string, -): Promise { - try { - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length === 0) { - throw new Error("No instance types available from server"); - } - - return skuList.skus; - } catch (err: any) { - console.warn(`Failed to fetch instance types: ${err.message}`); - // Return a default fallback - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; - } -} diff --git a/packages/cli/src/utils/__tests__/exitCodes.test.ts b/packages/cli/src/utils/__tests__/exitCodes.test.ts index a0dbd108..7f757811 100644 --- a/packages/cli/src/utils/__tests__/exitCodes.test.ts +++ b/packages/cli/src/utils/__tests__/exitCodes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { EXIT_CODES, errorMessage, stageFailure } from "../exitCodes"; +import { InsufficientGasError } from "@layr-labs/ecloud-sdk"; describe("errorMessage", () => { it("extracts Error.message", () => { @@ -43,6 +44,22 @@ describe("stageFailure — maps a failed deploy/upgrade stage to message + exit expect(onchain.message).toContain("re-running upgrade will reuse it"); }); + it("reclassifies an insufficient-gas failure caught in the build stage as on-chain (exit 4)", () => { + // The gas pre-flight runs inside prepare*() AFTER the image is built+pushed, + // so it surfaces through the build try/catch. But the image already exists, + // so it must NOT be reported as exit 3 "no was attempted" — it is an + // on-chain-readiness failure (exit 4, "re-run reuses the pushed image"). + const gasErr = new InsufficientGasError({ + address: "0xabc0000000000000000000000000000000000abc", + requiredWei: BigInt(2), + availableWei: BigInt(1), + }); + const { message, exit } = stageFailure("deploy", "build", gasErr); + expect(exit).toBe(EXIT_CODES.ONCHAIN_FAILED); + expect(message).toContain("Insufficient ETH for gas"); + expect(message).not.toContain("no deployment was attempted"); + }); + it("each stage carries a distinct exit code", () => { const exits = (["invalid-input", "build", "onchain"] as const).map( (s) => stageFailure("deploy", s, "e").exit, diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index b28dd73d..54894bb2 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -372,4 +372,18 @@ describe("collectMissingRequiredInputs reports all missing at once", () => { const m = collectMissingRequiredInputs({}, "app-id"); expect(m).toEqual([expect.stringMatching(/image source/)]); }); + it("requires --image-ref as the push destination when building from --dockerfile", () => { + // A local Dockerfile build still needs somewhere to push the built image. + // Non-interactively, a missing --image-ref must be reported here (exit 2), + // not deferred to an interactive prompt that then throws as exit 1. + const m = collectMissingRequiredInputs({ dockerfile: "Dockerfile", name: "n" }, "name"); + expect(m).toEqual([expect.stringMatching(/--image-ref/)]); + }); + it("accepts --dockerfile together with --image-ref", () => { + const m = collectMissingRequiredInputs( + { dockerfile: "Dockerfile", imageRef: "r", name: "n" }, + "name", + ); + expect(m).toEqual([]); + }); }); diff --git a/packages/cli/src/utils/exitCodes.ts b/packages/cli/src/utils/exitCodes.ts index b81ac7c0..760a1ad5 100644 --- a/packages/cli/src/utils/exitCodes.ts +++ b/packages/cli/src/utils/exitCodes.ts @@ -1,3 +1,5 @@ +import { InsufficientGasError } from "@layr-labs/ecloud-sdk"; + /** * Distinct process exit codes for deploy/upgrade so a caller (CI, agent) keying * off exit status can tell *which stage* failed. @@ -42,6 +44,12 @@ export type DeployStage = "invalid-input" | "build" | "onchain"; * * - build: no image was produced, so no on-chain tx was attempted (exit 3). * - onchain: the image is already built+pushed; a re-run reuses it (exit 4). + * + * Exception: the gas pre-flight (`assertSufficientGas`) runs inside prepare*() + * AFTER the image is built and pushed, so an `InsufficientGasError` surfaces + * through the build try/catch. The image already exists, so it is reclassified + * as an on-chain-readiness failure (exit 4) rather than a build failure (exit + * 3) — reporting "no was attempted" would be wrong and misleading. */ export function stageFailure( operation: DeployOperation, @@ -49,6 +57,13 @@ export function stageFailure( err: unknown, ): { message: string; exit: DeployStageExitCode } { const noun = operation === "deploy" ? "deployment" : "upgrade"; + + // The image is already pushed by the time gas is checked, so treat an + // insufficient-gas failure as on-chain regardless of which stage caught it. + if (err instanceof InsufficientGasError) { + stage = "onchain"; + } + switch (stage) { case "invalid-input": return { message: errorMessage(err), exit: EXIT_CODES.INVALID_INPUT }; diff --git a/packages/cli/src/utils/instanceTypes.ts b/packages/cli/src/utils/instanceTypes.ts new file mode 100644 index 00000000..fec2b8a9 --- /dev/null +++ b/packages/cli/src/utils/instanceTypes.ts @@ -0,0 +1,40 @@ +import { UserApiClient } from "@layr-labs/ecloud-sdk"; +import { createViemClients } from "./viemClients"; +import { getClientId } from "./version"; +import type { SkuInfo } from "./prompts"; + +/** + * Fetch the instance types (SKUs) offered by the backend for an environment. + * + * Best-effort: on any failure (network, auth, empty list) this warns and returns + * a single safe fallback SKU rather than aborting, so deploy/upgrade can still + * proceed with a sensible default. + */ +export async function fetchAvailableInstanceTypes( + environment: string, + environmentConfig: any, + privateKey: string, + rpcUrl: string, +): Promise { + try { + const { publicClient, walletClient } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + + const skuList = await userApiClient.getSKUs(); + if (skuList.skus.length === 0) { + throw new Error("No instance types available from server"); + } + + return skuList.skus; + } catch (err: any) { + console.warn(`Failed to fetch instance types: ${err.message}`); + // Return a default fallback + return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; + } +} diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 58e01089..58ae8a45 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -123,6 +123,11 @@ export function collectMissingRequiredInputs( missing.push( "an image source (one of: --image-ref, --dockerfile, or --verifiable with --repo and --commit)", ); + } else if (state.dockerfile && !state.imageRef && !state.verifiable) { + // A local --dockerfile build still needs a registry destination to push + // the built image to. Without --image-ref, the non-interactive run would + // otherwise fall through to an interactive --image-ref prompt and throw. + missing.push("--image-ref (registry destination for the built image)"); } if (identityFlag === "name" && !state.name) { missing.push("--name"); From dde9727c44a98c0962f1970cf913ff4870580ad4 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Fri, 5 Jun 2026 18:12:34 -0300 Subject: [PATCH 54/55] fix(sdk): unify amd64 remediation text with the CLI prebuilt-image path The platform-rejection message from digest.ts (hit by the prepare.ts pre-flight for a plain --image-ref) still pointed at `docker build` + "use the SDK", while the CLI's prebuilt-verifiable path (dockerhub.ts) already recommended `docker buildx ... --push` OR a server-side verifiable build (--verifiable --repo --commit ). An arm64 --image-ref is exactly the case where the verifiable-build escape hatch is most useful, so both arm64 entry points now give the same remediation. Updated createPlatformErrorMessage to match dockerhub.ts and asserted both `buildx` and `--verifiable --repo --commit` appear. SDK + CLI unit suites pass; prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/registry/__tests__/digest.test.ts | 16 ++++++++++++++++ .../sdk/src/client/common/registry/digest.ts | 13 +++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts index 6ab4f1bb..05c249b7 100644 --- a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts +++ b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts @@ -56,4 +56,20 @@ describe("getImageDigestAndName amd64 enforcement", () => { }); await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); }); + + it("remediation offers buildx and the --verifiable --repo --commit server-side path", async () => { + // The remediation must point at both fixes a non-amd64 --image-ref has: + // rebuild for amd64 with buildx, OR switch to a server-side verifiable build + // (which needs no local Docker at all). Kept consistent with the CLI's + // prebuilt-image rejection so both arm64 entry points say the same thing. + responses.manifest = JSON.stringify({ + manifests: [ + { digest: "sha256:" + "d".repeat(64), platform: { os: "linux", architecture: "arm64" } }, + ], + }); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/buildx/); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow( + /--verifiable --repo .* --commit/, + ); + }); }); diff --git a/packages/sdk/src/client/common/registry/digest.ts b/packages/sdk/src/client/common/registry/digest.ts index 836bf730..69324894 100644 --- a/packages/sdk/src/client/common/registry/digest.ts +++ b/packages/sdk/src/client/common/registry/digest.ts @@ -229,14 +229,11 @@ Image: ${imageRef} Found platform(s): ${platforms.join(", ")} Required platform: ${DOCKER_PLATFORM} -To fix this issue: -1. Manual fix: - a. Rebuild your image with the correct platform: - docker build --platform ${DOCKER_PLATFORM} -t ${imageRef} . - b. Push the rebuilt image to your remote registry: - docker push ${imageRef} - -2. Or use the SDK to build with the correct platform automatically.`; +To fix, either: +1. Rebuild the image for ${DOCKER_PLATFORM} and push it: + docker buildx build --platform ${DOCKER_PLATFORM} -t ${imageRef} --push . +2. Or use a verifiable build (--verifiable --repo --commit ), which + builds server-side and needs no local Docker.`; return new Error(errorMsg); } From 056f9966e44095f239f539edb5d7f9c3162021f1 Mon Sep 17 00:00:00 2001 From: mpjunior92 Date: Tue, 9 Jun 2026 12:42:43 -0300 Subject: [PATCH 55/55] fix(cli): app status --wait returns immediately on settled statuses; keep --json pure Addresses review feedback on #164: - --wait now does a one-shot read first and only blocks while the app is in a transitional status (created/deploying/upgrading/resuming/stopping/ terminating), matched case-insensitively. Settled statuses (Running, Stopped, Terminated, Suspended, Failed) return immediately instead of polling until the watch timeout. - --wait --json no longer corrupts stdout: the SDK compute module now accepts a logger override, and the command routes SDK progress output ("Waiting for app to start...", "Status: ...") to stderr in JSON mode so stdout stays a single JSON object. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/client.ts | 17 ++++- .../compute/app/__tests__/status.test.ts | 51 ++++++++++++++- .../cli/src/commands/compute/app/status.ts | 63 +++++++++++++++++-- .../src/client/modules/compute/app/index.ts | 11 +++- .../sdk/src/client/modules/compute/index.ts | 8 +++ 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index dfea37f8..7d09f938 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -5,13 +5,27 @@ import { createAdminModule, getEnvironmentConfig, requirePrivateKey, + type Logger, } from "@layr-labs/ecloud-sdk"; import { CommonFlags, validateCommonFlags } from "./flags"; import { getClientId } from "./utils/version"; import { createViemClients } from "./utils/viemClients"; import { Hex } from "viem"; -export async function createComputeClient(flags: CommonFlags) { +/** Options for {@link createComputeClient}. */ +export interface CreateComputeClientOptions { + /** + * Logger override forwarded to the SDK compute module. Commands emitting + * machine-readable output (`--json`) pass a stderr-routed logger so SDK + * progress messages never corrupt stdout. + */ + logger?: Logger; +} + +export async function createComputeClient( + flags: CommonFlags, + options: CreateComputeClientOptions = {}, +) { flags = await validateCommonFlags(flags); const environment = flags.environment; @@ -39,6 +53,7 @@ export async function createComputeClient(flags: CommonFlags) { environment, clientId: getClientId(), skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry + logger: options.logger, }); } diff --git a/packages/cli/src/commands/compute/app/__tests__/status.test.ts b/packages/cli/src/commands/compute/app/__tests__/status.test.ts index 888ef23b..5165bae6 100644 --- a/packages/cli/src/commands/compute/app/__tests__/status.test.ts +++ b/packages/cli/src/commands/compute/app/__tests__/status.test.ts @@ -26,8 +26,9 @@ vi.mock("../../../../utils/viemClients", () => ({ createViemClients: vi.fn(() => ({ publicClient: {}, walletClient: {} })), })); const watchDeployment = vi.fn(); +const createComputeClient = vi.fn(async () => ({ app: { watchDeployment } })); vi.mock("../../../../client", () => ({ - createComputeClient: vi.fn(async () => ({ app: { watchDeployment } })), + createComputeClient: (...args: unknown[]) => createComputeClient(...(args as [])), })); describe("compute app status", () => { @@ -68,11 +69,55 @@ describe("compute app status", () => { expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Unknown" }); }); - it("--wait blocks via watchDeployment, then does a final status read", async () => { + it("--wait blocks via watchDeployment for transitional statuses, then does a final status read", async () => { + // Initial read: still deploying -> should wait. Final read: settled. + getStatuses + .mockResolvedValueOnce([{ address: APP, status: "Deploying" }]) + .mockResolvedValueOnce([{ address: APP, status: "Running" }]); watchDeployment.mockResolvedValue(undefined); - getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); const out = await runCommand({ wait: true, json: true, "watch-timeout": 30 }); expect(watchDeployment).toHaveBeenCalledWith(APP, { timeoutSeconds: 30 }); expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Running" }); }); + + it("--wait returns immediately for Running without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); + const out = await runCommand({ wait: true, json: false }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(out.join("\n")).toContain("Running"); + }); + + it("--wait returns immediately for Terminated without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Terminated" }]); + const out = await runCommand({ wait: true, json: true }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Terminated" }); + }); + + it("--wait returns immediately for Stopped without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Stopped" }]); + const out = await runCommand({ wait: true, json: true }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Stopped" }); + }); + + it("--wait --json keeps stdout pure JSON by routing SDK progress off stdout", async () => { + // Transitional initial status forces a watch; the SDK progress logger + // must not be allowed to write to stdout or it corrupts the JSON. + getStatuses + .mockResolvedValueOnce([{ address: APP, status: "Deploying" }]) + .mockResolvedValueOnce([{ address: APP, status: "Running" }]); + watchDeployment.mockResolvedValue(undefined); + + const out = await runCommand({ wait: true, json: true, "watch-timeout": 30 }); + + // The compute client must be built with a logger override so the SDK + // does not print "Waiting for app to start..." / "Status: ..." to stdout. + const clientOpts = createComputeClient.mock.calls[0]?.[1] as { logger?: unknown } | undefined; + expect(clientOpts?.logger).toBeDefined(); + + // stdout is exactly one line, and that line is the JSON object. + expect(out).toHaveLength(1); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Running" }); + }); }); diff --git a/packages/cli/src/commands/compute/app/status.ts b/packages/cli/src/commands/compute/app/status.ts index cb19766e..7dad7174 100644 --- a/packages/cli/src/commands/compute/app/status.ts +++ b/packages/cli/src/commands/compute/app/status.ts @@ -69,10 +69,19 @@ export default class AppStatus extends Command { clientId: getClientId(), }); - if (flags.wait) { + // One-shot read first. --wait only blocks while the app is still + // transitioning; a settled status (Running/Stopped/Terminated/Failed/...) + // returns immediately instead of polling until the watch timeout. + const initialStatus = await this.readStatus(userApiClient, appID); + + if (flags.wait && isTransitionalStatus(initialStatus)) { // Reuse the bounded watch machinery: polls server-side at a // fixed cadence with 429/5xx backoff, throws WatchTimeoutError on timeout. - const compute = await createComputeClient(validatedFlags); + // In --json mode, route SDK progress to stderr so stdout stays pure JSON. + const compute = await createComputeClient( + validatedFlags, + flags.json ? { logger: stderrLogger } : {}, + ); try { await compute.app.watchDeployment(appID, { timeoutSeconds: flags["watch-timeout"], @@ -90,21 +99,63 @@ export default class AppStatus extends Command { throw err; } } + + // Final read after waiting; the status has (hopefully) settled. + const finalStatus = await this.readStatus(userApiClient, appID); + this.emit(appID, finalStatus, flags.json); + return; } - // One-shot status read (also the final read after --wait). + this.emit(appID, initialStatus, flags.json); + } + + /** Fetch the current status for an app, defaulting to "Unknown". */ + private async readStatus(userApiClient: UserApiClient, appID: string): Promise { const statuses = await userApiClient.getStatuses([appID]); - const status = statuses[0]?.status || "Unknown"; + return statuses[0]?.status || "Unknown"; + } - if (flags.json) { + /** Write the status as JSON (machine-readable) or a formatted line. */ + private emit(appID: string, status: string, json: boolean): void { + if (json) { this.log(JSON.stringify({ appId: appID, status })); return; } - this.log(`${chalk.bold(appID)}: ${formatStatus(status)}`); } } +/** + * Statuses that represent an in-progress transition the orchestrator will move + * out of on its own. Only these are worth blocking on with --wait; any other + * (settled) status returns immediately. Compared case-insensitively so a casing + * change on the server side doesn't silently turn --wait into a no-op. + */ +const TRANSITIONAL_STATUSES = new Set([ + "created", + "deploying", + "upgrading", + "resuming", + "stopping", + "terminating", +]); + +function isTransitionalStatus(status: string): boolean { + return TRANSITIONAL_STATUSES.has(status.toLowerCase()); +} + +/** + * Logger that routes every level to stderr. Used in --json mode so SDK + * progress output ("Waiting for app to start...", "Status: ...") never + * pollutes the JSON object on stdout. + */ +const stderrLogger = { + debug: (...args: unknown[]) => console.error(...args), + info: (...args: unknown[]) => console.error(...args), + warn: (...args: unknown[]) => console.error(...args), + error: (...args: unknown[]) => console.error(...args), +}; + function formatStatus(status: string): string { switch (status.toLowerCase()) { case "running": diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index be1ec10d..f663d9d5 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -57,6 +57,7 @@ import type { PrepareUpgradeFromVerifiableBuildOpts, PreparedDeploy, PreparedUpgrade, + Logger, } from "../../../common/types"; import { getLogger } from "../../../common/utils"; @@ -181,6 +182,12 @@ export interface AppModuleConfig { environment: string; clientId?: string; skipTelemetry?: boolean; // Skip telemetry when called from CLI + /** + * Optional logger override. Defaults to a stdout/stderr logger that respects + * `verbose`. Callers producing machine-readable output pass a logger that + * keeps progress off stdout. + */ + logger?: Logger; } export function createAppModule(ctx: AppModuleConfig): AppModule { @@ -196,8 +203,8 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { // Pull config for selected Environment const environment = getEnvironmentConfig(ctx.environment); - // Get logger that respects verbose setting - const logger = getLogger(ctx.verbose); + // Use the caller-provided logger if any, else one that respects verbose. + const logger = ctx.logger ?? getLogger(ctx.verbose); return { async create(opts) { diff --git a/packages/sdk/src/client/modules/compute/index.ts b/packages/sdk/src/client/modules/compute/index.ts index 8c87ccc6..d0726fd2 100644 --- a/packages/sdk/src/client/modules/compute/index.ts +++ b/packages/sdk/src/client/modules/compute/index.ts @@ -3,6 +3,7 @@ */ import { type WalletClient, type PublicClient } from "viem"; +import { type Logger } from "../../common/types"; import { createAppModule, type AppModule } from "./app"; export interface ComputeModule { @@ -16,6 +17,13 @@ export interface ComputeModuleConfig { environment: string; clientId?: string; skipTelemetry?: boolean; + /** + * Optional logger override. When provided, the module routes all progress + * output through it instead of the default stdout/stderr logger. Callers + * emitting machine-readable output (e.g. `--json`) pass a logger that writes + * to stderr so stdout stays pure. + */ + logger?: Logger; } export function createComputeModule(config: ComputeModuleConfig): ComputeModule {