@lizard-build/cli 4.0.23 → 4.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
1
+ import chalk from "chalk";
2
+ import open from "open";
3
+ import * as p from "@clack/prompts";
4
+ import { Command } from "commander";
5
+ import { api, withQuery, getBaseURL, isNotFound } from "../lib/api.js";
6
+ import { success, isJSONMode, printJSON, table, isTTY, timeAgo, info, warn } from "../lib/format.js";
7
+
8
+ // `lizard billing` — the account's plan (docs/pro-billing-api.md on the platform
9
+ // repo). Pro is $19/month, taxes included, with $19 of monthly credits; usage above
10
+ // that is pay as you go. Unlike most commands this never takes --project/--service:
11
+ // billing belongs to the account, not a workspace. `lizard credits` is the old name.
12
+
13
+ /** Old prepaid credits (`plan: "payg"`) keep working until this date. */
14
+ export const CREDITS_END = "November 1, 2026";
15
+ const BILLING_PATH = "/profile/account-billing";
16
+
17
+ export function billingUrl(): string {
18
+ return `${getBaseURL()}${BILLING_PATH}`;
19
+ }
20
+
21
+ export interface Subscription {
22
+ plan: "none" | "pro" | "payg" | "enterprise" | string;
23
+ status: "none" | "trialing" | "active" | "past_due" | "canceled" | string;
24
+ isOwner?: boolean;
25
+ priceCents: number;
26
+ taxIncluded: boolean;
27
+ includedCents: number;
28
+ trial: {
29
+ eligible: boolean;
30
+ days: number | null;
31
+ creditCents: number | null;
32
+ promoCode: string | null;
33
+ endsAt: number | null;
34
+ usedCents: number | null;
35
+ remainingCents: number | null;
36
+ };
37
+ period: {
38
+ kind: "trial" | "paid";
39
+ start: number;
40
+ end: number;
41
+ includedCents: number;
42
+ usedCents: number;
43
+ overageCents: number;
44
+ billedOverageCents: number;
45
+ unbilledOverageCents: number;
46
+ nextOverageChargeAtCents: number | null;
47
+ } | null;
48
+ nextCharge: { at: number | null; amountCents: number } | null;
49
+ cancelAt: number | null;
50
+ pastDue: boolean;
51
+ openInvoiceUrl: string | null;
52
+ paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
53
+ limits: { tier: string; replicasPerApp: number } | null;
54
+ checkoutAvailable: boolean;
55
+ }
56
+
57
+ interface BalanceView {
58
+ plan: string;
59
+ status: "active" | "grace" | "frozen";
60
+ balanceCents: number;
61
+ overdraftLimitCents: number | null;
62
+ availableCents: number | null;
63
+ expiringCents: number;
64
+ expiringAt: number | null;
65
+ hourlyRateCents: number;
66
+ runwayHours: number | null;
67
+ graceSince: number | null;
68
+ graceHours: number;
69
+ neverFreeze: boolean;
70
+ invoicedMonthly: boolean;
71
+ autoTopup?: { enabled: boolean; thresholdCents: number; amountCents: number; disabledReason: string | null };
72
+ pendingPromo: { code: string; creditCents: number; expiresAt: number | null } | null;
73
+ }
74
+
75
+ interface CheckoutResult {
76
+ url: string;
77
+ sessionId: string;
78
+ trialDays: number | null;
79
+ trialCreditCents: number | null;
80
+ }
81
+
82
+ interface StartNowResult {
83
+ status: "active" | "requires_action" | "failed" | string;
84
+ invoiceUrl?: string | null;
85
+ }
86
+
87
+ /** New servers add the trial fields; older ones send only the first four. */
88
+ export interface PromoResult {
89
+ status: string;
90
+ code?: string;
91
+ creditCents: number;
92
+ expiresAt: number | null;
93
+ balanceCents: number;
94
+ trialDays?: number | null;
95
+ trialCreditCents?: number | null;
96
+ appliesTo?: "next_checkout" | "current_trial";
97
+ }
98
+
99
+ interface Transaction {
100
+ id: string; kind: string; amountCents: number; balanceAfterCents: number | null;
101
+ description: string | null; createdAt: number;
102
+ }
103
+
104
+ interface PaymentMethod {
105
+ id: string; brand: string; last4: string; expMonth: number; expYear: number; isDefault?: boolean;
106
+ }
107
+
108
+ function fmtCents(cents: number): string {
109
+ const sign = cents < 0 ? "-" : "";
110
+ return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
111
+ }
112
+
113
+ /** $19, $4.10: whole dollars without cents. */
114
+ export function usd(cents: number): string {
115
+ return cents % 100 === 0 ? `$${cents / 100}` : fmtCents(cents);
116
+ }
117
+
118
+ function fmtDate(ms: number): string {
119
+ return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
120
+ }
121
+
122
+ function daysLeft(endMs: number, now = Date.now()): string {
123
+ const days = Math.max(0, Math.ceil((endMs - now) / 86_400_000));
124
+ return days === 1 ? "1 day left" : `${days} days left`;
125
+ }
126
+
127
+ function statusLabel(status: string): string {
128
+ switch (status) {
129
+ case "active": return chalk.green(status);
130
+ case "trialing": return chalk.green("trial");
131
+ case "grace":
132
+ case "past_due": return chalk.yellow(status.replace("_", " "));
133
+ case "frozen":
134
+ case "canceled": return chalk.red(status);
135
+ default: return status;
136
+ }
137
+ }
138
+
139
+ const creditsNotice = () => `Prepaid credits end on ${CREDITS_END}. Start Pro before then: lizard billing start`;
140
+
141
+ /** Lines that describe a plan, for people. Pure, so the tests can read it. */
142
+ export function describeSubscription(sub: Subscription, now = Date.now()): string[] {
143
+ const lines: string[] = [];
144
+ const label = (s: string) => chalk.dim(s);
145
+ const owner = sub.isOwner === false ? chalk.dim(" (workspace owner's plan)") : "";
146
+
147
+ if (sub.plan === "payg") {
148
+ lines.push(`${chalk.bold("Prepaid credits")}${owner}`);
149
+ lines.push(chalk.yellow(creditsNotice()));
150
+ return lines;
151
+ }
152
+
153
+ if (sub.plan === "enterprise") {
154
+ lines.push(`${chalk.bold("Enterprise")}${owner}`);
155
+ lines.push("Pay as you go, invoiced monthly.");
156
+ return lines;
157
+ }
158
+
159
+ if (sub.plan !== "pro") {
160
+ lines.push(`${chalk.bold("No plan")}${owner}`);
161
+ if (sub.status === "canceled") lines.push("Pro has ended.");
162
+ if (sub.isOwner === false) {
163
+ lines.push("The workspace owner has to start Pro.");
164
+ } else if (!sub.checkoutAvailable) {
165
+ lines.push("Pro is not open for this account yet.");
166
+ } else if (sub.trial.eligible && sub.trial.days) {
167
+ lines.push(
168
+ `Start a ${sub.trial.days}-day Pro trial with ${usd(sub.trial.creditCents ?? 0)} in credits: ` +
169
+ chalk.cyan("lizard billing start"),
170
+ );
171
+ lines.push(chalk.dim(`No charge today, then ${usd(sub.priceCents)}/month, taxes included.`));
172
+ if (sub.trial.promoCode) lines.push(label("Promo code: ") + `${sub.trial.promoCode} (saved for the trial)`);
173
+ } else {
174
+ lines.push(
175
+ `Start Pro: ${usd(sub.priceCents)}/month, taxes included, with ${usd(sub.includedCents)} in credits each month: ` +
176
+ chalk.cyan("lizard billing start"),
177
+ );
178
+ }
179
+ return lines;
180
+ }
181
+
182
+ lines.push(
183
+ `${chalk.bold("Pro")} ${statusLabel(sub.status)}${owner} ` +
184
+ chalk.dim(`${usd(sub.priceCents)}/month, taxes included`),
185
+ );
186
+
187
+ const period = sub.period;
188
+ if (sub.status === "trialing") {
189
+ if (sub.trial.endsAt) lines.push(label("Trial: ") + `${daysLeft(sub.trial.endsAt, now)}, ends ${fmtDate(sub.trial.endsAt)}`);
190
+ const credit = sub.trial.creditCents ?? period?.includedCents ?? 0;
191
+ const used = sub.trial.usedCents ?? period?.usedCents ?? 0;
192
+ const left = sub.trial.remainingCents ?? Math.max(0, credit - used);
193
+ lines.push(label("Trial credits: ") + `${usd(used)} of ${usd(credit)} used, ${usd(left)} left`);
194
+ } else if (period) {
195
+ lines.push(
196
+ label("This month: ") +
197
+ `${usd(period.usedCents)} of ${usd(period.includedCents)} used` +
198
+ chalk.dim(` (${fmtDate(period.start)} to ${fmtDate(period.end)})`),
199
+ );
200
+ if (period.kind === "paid") {
201
+ let overage = `${usd(period.overageCents)} this month`;
202
+ if (period.overageCents > 0) {
203
+ overage += `, ${usd(period.billedOverageCents)} invoiced so far`;
204
+ if (period.nextOverageChargeAtCents) {
205
+ overage += `; the next invoice goes out at ${usd(period.nextOverageChargeAtCents)}`;
206
+ }
207
+ }
208
+ lines.push(label("Overage: ") + overage);
209
+ }
210
+ }
211
+
212
+ if (sub.nextCharge?.at) {
213
+ lines.push(label("Next charge: ") + `${usd(sub.nextCharge.amountCents)} on ${fmtDate(sub.nextCharge.at)}`);
214
+ }
215
+ if (sub.cancelAt) {
216
+ lines.push(chalk.yellow(`Ends on ${fmtDate(sub.cancelAt)}.`) + " Run " + chalk.cyan("lizard billing resume") + " to keep Pro.");
217
+ }
218
+ if (sub.pastDue) {
219
+ lines.push(chalk.red("A payment failed. New resources are blocked until the open invoice is paid."));
220
+ if (sub.openInvoiceUrl) lines.push(label("Pay it: ") + chalk.cyan(sub.openInvoiceUrl));
221
+ }
222
+ if (sub.limits) {
223
+ const n = sub.limits.replicasPerApp;
224
+ lines.push(label("Replicas: ") + `up to ${n} per service`);
225
+ }
226
+ if (sub.paymentMethod) {
227
+ const pm = sub.paymentMethod;
228
+ lines.push(label("Card: ") + `${pm.brand} •••• ${pm.last4}` + chalk.dim(` (expires ${pm.expMonth}/${pm.expYear})`));
229
+ }
230
+ return lines;
231
+ }
232
+
233
+ async function fetchBalance(): Promise<BalanceView> {
234
+ return api.get<BalanceView>("/api/billing/balance");
235
+ }
236
+
237
+ /** The prepaid credits view (old `payg` accounts until November 1, and enterprise usage). */
238
+ function printBalanceView(view: BalanceView) {
239
+ console.log(`${chalk.bold(fmtCents(view.balanceCents))} ${statusLabel(view.status)} ${chalk.dim(`(${view.plan})`)}`);
240
+
241
+ if (view.neverFreeze) {
242
+ console.log(chalk.dim("Exempt from billing freeze — balance can go negative with no enforcement."));
243
+ } else {
244
+ if (view.overdraftLimitCents != null) {
245
+ console.log(chalk.dim(`Overdraft limit: `) + fmtCents(-view.overdraftLimitCents));
246
+ }
247
+ if (view.availableCents != null) {
248
+ console.log(chalk.dim(`Available before freeze: `) + fmtCents(view.availableCents));
249
+ }
250
+ if (view.graceSince) {
251
+ const deadline = view.graceSince + view.graceHours * 3_600_000;
252
+ const hoursLeft = Math.max(0, Math.round((deadline - Date.now()) / 3_600_000));
253
+ console.log(chalk.yellow(`In grace since ${timeAgo(view.graceSince)} — freezes in ~${hoursLeft}h if unpaid.`));
254
+ }
255
+ }
256
+
257
+ if (view.expiringCents > 0 && view.expiringAt) {
258
+ console.log(chalk.dim(`Expiring: `) + `${fmtCents(view.expiringCents)} by ${new Date(view.expiringAt).toLocaleDateString()}`);
259
+ }
260
+
261
+ if (view.hourlyRateCents > 0) {
262
+ const rate = chalk.dim(`(~${fmtCents(view.hourlyRateCents)}/hr)`);
263
+ if (view.runwayHours != null) {
264
+ const days = (view.runwayHours / 24).toFixed(1);
265
+ console.log(chalk.dim(`Runway: `) + `~${view.runwayHours}h (${days}d) at current usage ${rate}`);
266
+ } else {
267
+ console.log(chalk.dim(`Current usage rate: `) + rate);
268
+ }
269
+ }
270
+
271
+ if (view.autoTopup) {
272
+ console.log(
273
+ chalk.dim("Auto top-up: ") +
274
+ (view.autoTopup.enabled
275
+ ? `on — tops up ${fmtCents(view.autoTopup.amountCents)} when balance drops below ${fmtCents(view.autoTopup.thresholdCents)}`
276
+ : "off"),
277
+ );
278
+ if (view.autoTopup.disabledReason) {
279
+ console.log(chalk.yellow(` disabled: ${view.autoTopup.disabledReason}`));
280
+ }
281
+ }
282
+
283
+ if (view.pendingPromo) {
284
+ console.log(chalk.dim("Pending promo: ") + `${view.pendingPromo.code} (${fmtCents(view.pendingPromo.creditCents)})`);
285
+ }
286
+ }
287
+
288
+ async function showStatus(opts: { workspace?: string }) {
289
+ let sub: Subscription;
290
+ try {
291
+ sub = await api.get<Subscription>(withQuery("/api/billing/subscription", { workspaceId: opts.workspace }));
292
+ } catch (e) {
293
+ // A server without Pro yet: the prepaid credits view is all there is.
294
+ if (!isNotFound(e) || opts.workspace) throw e;
295
+ const view = await fetchBalance();
296
+ if (isJSONMode()) printJSON(view);
297
+ else printBalanceView(view);
298
+ return;
299
+ }
300
+
301
+ const url = billingUrl();
302
+ const ownPrepaid = sub.plan === "payg" && sub.isOwner !== false;
303
+ const balance = ownPrepaid ? await fetchBalance() : null;
304
+
305
+ if (isJSONMode()) {
306
+ printJSON({
307
+ ...sub,
308
+ billingUrl: url,
309
+ ...(balance ? { balance, notice: `Prepaid credits end on ${CREDITS_END}.` } : {}),
310
+ });
311
+ return;
312
+ }
313
+
314
+ if (sub.plan === "payg") {
315
+ if (balance) printBalanceView(balance);
316
+ else console.log(`${chalk.bold("Prepaid credits")}${chalk.dim(" (workspace owner's plan)")}`);
317
+ console.log(chalk.yellow(creditsNotice()));
318
+ } else {
319
+ for (const line of describeSubscription(sub)) console.log(line);
320
+ }
321
+ console.log(chalk.dim("Billing: ") + chalk.cyan(url));
322
+ }
323
+
324
+ async function confirmOrExit(message: string, yes: boolean | undefined) {
325
+ if (yes) return;
326
+ if (!isTTY()) throw new Error("Use -y to confirm in non-interactive mode");
327
+ const confirmed = await p.confirm({ message });
328
+ if (p.isCancel(confirmed) || !confirmed) process.exit(5);
329
+ }
330
+
331
+ async function openInBrowser(url: string, wanted: boolean) {
332
+ if (wanted && isTTY() && !isJSONMode()) await open(url).catch(() => {});
333
+ }
334
+
335
+ /** One line for a redeemed promo code. Works with servers before and after Pro. */
336
+ export function describePromo(out: PromoResult): string {
337
+ if (out.trialDays != null) {
338
+ const credits = usd(out.trialCreditCents ?? out.creditCents);
339
+ if (out.appliesTo === "current_trial") {
340
+ const until = out.expiresAt ? `, until ${fmtDate(out.expiresAt)}` : "";
341
+ return `Your trial now runs ${out.trialDays} days with ${credits} in credits${until}.`;
342
+ }
343
+ const code = out.code ? `Code ${out.code} saved: ` : "Code saved: ";
344
+ return `${code}a ${out.trialDays}-day Pro trial with ${credits} in credits. Start it with \`lizard billing start\`.`;
345
+ }
346
+ if (out.status === "pending_payment_method") {
347
+ return `Code saved: ${fmtCents(out.creditCents)} in credits. They unlock when you start your trial in Billing.`;
348
+ }
349
+ return `Redeemed ${fmtCents(out.creditCents)}. New balance: ${fmtCents(out.balanceCents)}.`;
350
+ }
351
+
352
+ export function registerBilling(program: Command) {
353
+ const cmd = program
354
+ .command("billing")
355
+ .alias("credits")
356
+ .description("Your plan: Pro trial, this month's credits, next charge, cancel or resume (`credits` is the old name)")
357
+ .option("-w, --workspace <id>", "Show the plan of this workspace's owner")
358
+ .action(async () => {
359
+ await showStatus(cmd.opts());
360
+ });
361
+
362
+ cmd.hook("preAction", (thisCommand) => {
363
+ if (thisCommand.parent?.args[0] === "credits") {
364
+ warn("`lizard credits` is now `lizard billing`. The old name will go away in a later release.");
365
+ }
366
+ });
367
+
368
+ cmd
369
+ .command("status")
370
+ .description("Show the plan, trial, this month's credits and the next charge")
371
+ .option("-w, --workspace <id>", "Show the plan of this workspace's owner")
372
+ .action(async (opts) => {
373
+ // `billing` takes -w too and claims it wherever it appears on the line.
374
+ await showStatus({ ...cmd.opts(), ...opts });
375
+ });
376
+
377
+ cmd
378
+ .command("open")
379
+ .description("Open the Billing page in the browser")
380
+ .option("--no-open", "Print the link instead of opening a browser")
381
+ .action(async (opts) => {
382
+ const url = billingUrl();
383
+ if (isJSONMode()) {
384
+ printJSON({ url, opened: false });
385
+ return;
386
+ }
387
+ console.log(chalk.cyan(url));
388
+ await openInBrowser(url, opts.open !== false);
389
+ });
390
+
391
+ cmd
392
+ .command("start")
393
+ .description("Start Pro in Stripe Checkout: the trial if the account can have one, otherwise Pro at once")
394
+ .option("--return-url <path>", "Dashboard path to come back to after Checkout, e.g. /projects/abc")
395
+ .option("--no-open", "Print the Checkout link instead of opening a browser")
396
+ .action(async (opts) => {
397
+ const out = await api.post<CheckoutResult>("/api/billing/subscription/checkout", { returnUrl: opts.returnUrl });
398
+ if (isJSONMode()) {
399
+ printJSON(out);
400
+ return;
401
+ }
402
+ if (out.trialDays) {
403
+ console.log(
404
+ `Your ${out.trialDays}-day trial includes ${usd(out.trialCreditCents ?? 0)} in credits. ` +
405
+ "No charge today, then $19/month, taxes included.",
406
+ );
407
+ } else {
408
+ console.log("Pro starts today: $19/month, taxes included, with $19 in credits each month.");
409
+ }
410
+ console.log(`Finish in Stripe Checkout:\n${chalk.cyan(out.url)}`);
411
+ await openInBrowser(out.url, opts.open !== false);
412
+ info(chalk.dim("Run `lizard billing` when you are done."));
413
+ });
414
+
415
+ cmd
416
+ .command("start-now")
417
+ .description("End the trial now: charge $19 and start the first paid month with $19 in credits")
418
+ .option("-y, --yes", "Skip confirmation")
419
+ .option("--no-open", "Print the payment link instead of opening a browser")
420
+ .action(async (opts) => {
421
+ await confirmOrExit("End the trial and charge $19 now?", opts.yes);
422
+ const out = await api.post<StartNowResult>("/api/billing/subscription/start-now", {});
423
+ if (out.status === "failed") {
424
+ throw Object.assign(
425
+ new Error(`The charge failed, so your trial continues. Check your card in Billing:\n ${billingUrl()}`),
426
+ { code: "PAYMENT_FAILED" },
427
+ );
428
+ }
429
+ if (isJSONMode()) {
430
+ printJSON(out);
431
+ return;
432
+ }
433
+ if (out.status === "requires_action" && out.invoiceUrl) {
434
+ console.log(`Your bank needs you to confirm the payment. Your trial continues until you do:\n${chalk.cyan(out.invoiceUrl)}`);
435
+ await openInBrowser(out.invoiceUrl, opts.open !== false);
436
+ return;
437
+ }
438
+ success("Pro is active. We charged $19 and added $19 in credits for this month.");
439
+ });
440
+
441
+ cmd
442
+ .command("cancel")
443
+ .description("Cancel Pro at the end of this month or trial; nothing is charged after that")
444
+ .option("-y, --yes", "Skip confirmation")
445
+ .action(async (opts) => {
446
+ await confirmOrExit("Cancel Pro at the end of the current period?", opts.yes);
447
+ const out = await api.post<{ cancelAt: number | null }>("/api/billing/subscription/cancel", {});
448
+ if (isJSONMode()) {
449
+ printJSON(out);
450
+ return;
451
+ }
452
+ success(
453
+ out.cancelAt
454
+ ? `Pro ends on ${fmtDate(out.cancelAt)}. Run \`lizard billing resume\` to keep it.`
455
+ : "Pro is cancelled.",
456
+ );
457
+ });
458
+
459
+ cmd
460
+ .command("resume")
461
+ .description("Undo a cancel: keep Pro after the current period")
462
+ .action(async () => {
463
+ const out = await api.post<{ cancelAt: null }>("/api/billing/subscription/resume", {});
464
+ if (isJSONMode()) {
465
+ printJSON(out);
466
+ return;
467
+ }
468
+ success("Pro continues. It renews as usual.");
469
+ });
470
+
471
+ cmd
472
+ .command("promo <code>")
473
+ .description("Redeem a promo code: a longer Pro trial with more trial credits")
474
+ .action(async (code: string) => {
475
+ const out = await api.post<PromoResult>("/api/billing/promo/redeem", { code });
476
+ if (isJSONMode()) {
477
+ printJSON(out);
478
+ return;
479
+ }
480
+ success(describePromo(out));
481
+ });
482
+
483
+ cmd
484
+ .command("balance")
485
+ .description(`Prepaid credits balance (old credits accounts until ${CREDITS_END}, and enterprise)`)
486
+ .action(async () => {
487
+ const view = await fetchBalance();
488
+ if (isJSONMode()) {
489
+ printJSON(view);
490
+ return;
491
+ }
492
+ printBalanceView(view);
493
+ });
494
+
495
+ cmd
496
+ .command("transactions")
497
+ .alias("ledger")
498
+ .description("List recent balance transactions (prepaid credits and enterprise accounts)")
499
+ .option("-l, --limit <n>", "Max results (1-100)", "20")
500
+ .option("-c, --cursor <cursor>", "Pagination cursor from a previous call")
501
+ .option("-u, --usage", "Include daily usage-deduction rows")
502
+ .action(async (opts) => {
503
+ const page = await api.get<{ items: Transaction[]; nextCursor: string | null }>(
504
+ withQuery("/api/billing/transactions", {
505
+ limit: opts.limit,
506
+ cursor: opts.cursor,
507
+ includeUsage: opts.usage ? "1" : undefined,
508
+ }),
509
+ );
510
+
511
+ if (isJSONMode()) {
512
+ printJSON(page);
513
+ return;
514
+ }
515
+
516
+ if (page.items.length === 0) {
517
+ console.log("No transactions.");
518
+ return;
519
+ }
520
+
521
+ table(
522
+ ["When", "Kind", "Amount", "Balance after", "Description"],
523
+ page.items.map((t) => [
524
+ timeAgo(t.createdAt),
525
+ t.kind,
526
+ t.amountCents >= 0 ? chalk.green(fmtCents(t.amountCents)) : chalk.red(fmtCents(t.amountCents)),
527
+ t.balanceAfterCents != null ? fmtCents(t.balanceAfterCents) : "—",
528
+ t.description ?? "",
529
+ ]),
530
+ );
531
+
532
+ if (page.nextCursor) {
533
+ console.log(chalk.dim(`\nMore results: lizard billing transactions --cursor ${page.nextCursor}`));
534
+ }
535
+ });
536
+
537
+ // ── Payment methods ───────────────────────────────────────────────
538
+ const pm = cmd.command("payment-methods").alias("pm").description("List or remove saved cards (add a card in Billing)");
539
+
540
+ pm.command("list")
541
+ .description("List saved cards")
542
+ .action(async () => {
543
+ const { items } = await api.get<{ items: PaymentMethod[] }>("/api/billing/payment-methods");
544
+ if (isJSONMode()) {
545
+ printJSON(items);
546
+ return;
547
+ }
548
+ if (items.length === 0) {
549
+ console.log(`No saved cards. Add one in Billing: ${chalk.cyan(billingUrl())}`);
550
+ return;
551
+ }
552
+ table(
553
+ ["ID", "Card", "Expires", "Default"],
554
+ items.map((m) => [m.id, `${m.brand} •••• ${m.last4}`, `${m.expMonth}/${m.expYear}`, m.isDefault ? "yes" : ""]),
555
+ );
556
+ });
557
+
558
+ pm.command("remove <id>")
559
+ .alias("rm")
560
+ .description("Remove a saved card")
561
+ .option("-y, --yes", "Skip confirmation")
562
+ .action(async (id: string, opts) => {
563
+ await confirmOrExit(`Remove payment method ${chalk.bold(id)}?`, opts.yes);
564
+ const out = await api.delete<{ ok: boolean }>(`/api/billing/payment-methods/${encodeURIComponent(id)}`);
565
+ if (isJSONMode()) {
566
+ printJSON(out);
567
+ } else {
568
+ success("Payment method removed");
569
+ }
570
+ });
571
+ }
@@ -47,7 +47,7 @@ export function registerMetrics(program: Command) {
47
47
  .option("--all", "Show all services in the project, ignoring the linked service")
48
48
  .option("-r, --range <range>", `Time range: ${RANGES.join("|")}`, "1h")
49
49
  .option("-w, --watch", "Live view, refreshed every 3s (Ctrl+C to stop)")
50
- .option("--cost", "Show running resources, cost per hour, and current billing-period usage (incl. egress)")
50
+ .option("--cost", "Show running resources, cost per hour, current billing-period usage (incl. egress) and Pro credits used this month")
51
51
  .action(async (opts) => {
52
52
  if (opts.all && opts.service) {
53
53
  fail("--all cannot be combined with --service. Choose all services or one service.");
@@ -521,6 +521,48 @@ async function fetchPeriodUsage(projectId: string, workspaceId: string): Promise
521
521
  };
522
522
  }
523
523
 
524
+ /** This month's Pro credits for the workspace owner's account; null for other plans. */
525
+ interface ProCredits {
526
+ status: string;
527
+ kind: "trial" | "paid";
528
+ periodStart: number;
529
+ periodEnd: number;
530
+ includedCents: number;
531
+ usedCents: number;
532
+ overageCents: number;
533
+ }
534
+
535
+ async function fetchProCredits(workspaceId: string): Promise<ProCredits | null> {
536
+ const sub = await api
537
+ .get<{
538
+ plan: string;
539
+ status: string;
540
+ period: { kind: "trial" | "paid"; start: number; end: number; includedCents: number; usedCents: number; overageCents: number } | null;
541
+ }>(withQuery("/api/billing/subscription", { workspaceId }))
542
+ .catch(() => null);
543
+ if (!sub || sub.plan !== "pro" || !sub.period) return null;
544
+ return {
545
+ status: sub.status,
546
+ kind: sub.period.kind,
547
+ periodStart: sub.period.start,
548
+ periodEnd: sub.period.end,
549
+ includedCents: sub.period.includedCents,
550
+ usedCents: sub.period.usedCents,
551
+ overageCents: sub.period.overageCents,
552
+ };
553
+ }
554
+
555
+ const dollars = (cents: number) => (cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`);
556
+
557
+ /** "$4.10 of $19 in monthly credits used, overage $0" — the account-wide line for Pro. */
558
+ export function proCreditsLine(pro: ProCredits): string {
559
+ if (pro.kind === "trial") {
560
+ const left = Math.max(0, pro.includedCents - pro.usedCents);
561
+ return `${dollars(pro.usedCents)} of ${dollars(pro.includedCents)} in trial credits used, ${dollars(left)} left`;
562
+ }
563
+ return `${dollars(pro.usedCents)} of ${dollars(pro.includedCents)} in monthly credits used, overage ${dollars(pro.overageCents)}`;
564
+ }
565
+
524
566
  function fmtPeriodDate(ms: number): string {
525
567
  return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" });
526
568
  }
@@ -532,10 +574,12 @@ async function showCost(projectId: string, scope: ResourceScope) {
532
574
 
533
575
  let data: { resources: BillingResource[]; costPerHour: number };
534
576
  let usage: PeriodUsage | null;
577
+ let pro: ProCredits | null;
535
578
  try {
536
- [data, usage] = await Promise.all([
579
+ [data, usage, pro] = await Promise.all([
537
580
  api.get(withQuery("/api/billing/live", { workspaceId: scope.workspaceId })),
538
581
  fetchPeriodUsage(projectId, scope.workspaceId),
582
+ fetchProCredits(scope.workspaceId),
539
583
  ]);
540
584
  } catch (e) {
541
585
  if (e instanceof APIError && e.status === 403) {
@@ -554,6 +598,8 @@ async function showCost(projectId: string, scope: ResourceScope) {
554
598
  projectCostPerHour: projectCost,
555
599
  workspaceCostPerHour: data.costPerHour,
556
600
  currentPeriod: usage,
601
+ // Pro only: the account's credits this month (the trial's while trialing).
602
+ ...(pro ? { proCredits: pro } : {}),
557
603
  });
558
604
  return;
559
605
  }
@@ -580,6 +626,7 @@ async function showCost(projectId: string, scope: ResourceScope) {
580
626
  );
581
627
  }
582
628
  console.log(chalk.dim("Workspace ") + `$${data.costPerHour.toFixed(4)}/hr`);
629
+ if (pro) console.log(chalk.dim(pro.kind === "trial" ? "Pro trial " : "Pro ") + proCreditsLine(pro));
583
630
 
584
631
  if (usage && usage.rows.length > 0) {
585
632
  console.log();
@@ -6,7 +6,7 @@ import { createTarball } from "../lib/archive.js";
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import * as readline from "node:readline";
9
- import { api, streamSSE, getBaseURL, clientHeaders, APIError, type ResourceScope } from "../lib/api.js";
9
+ import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, type ResourceScope } from "../lib/api.js";
10
10
  import { updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
11
11
  import { resolveContext, getScope } from "../lib/resolve.js";
12
12
  import { ensureLinked } from "./init.js";
@@ -191,13 +191,14 @@ async function deployFromLocal(args: {
191
191
  try {
192
192
  parsed = text ? JSON.parse(text) : null;
193
193
  } catch {}
194
- const detail = parsed?.error || parsed?.message || text || res.statusText;
195
- throw new APIError(
196
- res.status,
197
- `Upload failed (${res.status}): ${detail}`,
198
- parsed?.code || "",
199
- parsed,
200
- );
194
+ if (parsed && typeof parsed === "object") {
195
+ const err = apiErrorFrom(res.status, res.statusText, parsed);
196
+ // "Start Pro to deploy" is not an upload failure: print the platform's
197
+ // sentence and its Billing link as they are.
198
+ if (!isPaymentRequired(err)) err.message = `Upload failed (${res.status}): ${err.message}`;
199
+ throw err;
200
+ }
201
+ throw new APIError(res.status, `Upload failed (${res.status}): ${text || res.statusText}`, "", null);
201
202
  }
202
203
  newApp = (await res.json()) as App & { buildId?: string };
203
204
  spinner.succeed(`Service ${chalk.bold(newApp.name)} ${args.existingServiceId ? "updated" : "created"}`);