From a348ee454c1eac894f093f362d2f24ddee8c92bb Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 28 Oct 2026 09:51:44 +0100 Subject: [PATCH 1/2] feat: add upgrade and billing commands `pixelvault upgrade ` prints a Stripe Checkout URL and `pixelvault billing` prints a billing-portal URL, using the API's new key-authenticated POST /v1/billing/checkout and /portal (live secret key only). URL goes to stdout, guidance to stderr, --json prints { url }. Known API errors (test/publishable key, unverified email, already subscribed, no subscription, unavailable, rate limit) map to actionable messages. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 15 ++++ src/commands/billing.ts | 86 +++++++++++++++++++ src/commands/upgrade.ts | 55 ++++++++++++ src/index.ts | 2 + tests/commands/upgrade-billing.test.ts | 113 +++++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 src/commands/billing.ts create mode 100644 src/commands/upgrade.ts create mode 100644 tests/commands/upgrade-billing.test.ts diff --git a/README.md b/README.md index 3be2313..edec6c4 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ npx pixelvault-cli upload build-output.png | `export` | Export all project images as a tar archive | | `usage` | Show plan, period, and storage/upload/bandwidth usage and limits | | `keys list\|create\|revoke` | List, create, and revoke the project's secret API keys | +| `upgrade ` | Print a Stripe Checkout URL to subscribe (live key; you pay in the browser) | +| `billing` | Print a Stripe billing-portal URL to change plan, update payment details or cancel (live key) | | `whoami` | Show current auth state | | `config get\|set\|show` | Manage CLI configuration | @@ -171,6 +173,19 @@ pixelvault keys revoke key_abc123 `PIXELVAULT_API_KEY` to `$NEW`, then `pixelvault keys revoke ` using the new key. If you get locked out, use the dashboard's Regenerate. +### Upgrade and billing + +Both need a **live** secret key (`pv_live_...`); test and publishable keys get a 403. They print a Stripe URL for **you** to open — nothing is charged by the command, and the plan changes only after Stripe confirms payment. + +```bash +pixelvault upgrade starter # or: pro → Checkout URL (needs a verified email) +pixelvault usage # after paying: shows the new plan +pixelvault billing # portal URL: change plan, payment details, cancel +pixelvault upgrade pro --json # { "url": "..." } +``` + +Show the URLs only to the account owner: the portal can cancel the subscription. Limited to 10 billing requests per hour per account. + ## Configuration Config is stored at `~/.pixelvault/config.json` (0600 permissions). diff --git a/src/commands/billing.ts b/src/commands/billing.ts new file mode 100644 index 0000000..cb6dd56 --- /dev/null +++ b/src/commands/billing.ts @@ -0,0 +1,86 @@ +import { defineCommand } from "citty"; +import { apiRequest, ApiRequestError } from "../lib/client.js"; +import { requireApiKey } from "../lib/config.js"; +import { CliError } from "../lib/errors.js"; +import { stdout, stderr, jsonOut } from "../lib/output.js"; + +/** Turn known billing API errors into actionable messages. */ +export function explainBillingError(err: unknown): never { + if (err instanceof ApiRequestError) { + switch (err.code) { + case "test_key_forbidden": + throw new CliError( + "Billing needs a live key (pv_live_...); test keys can't start checkout or open the portal.", + 1 + ); + case "publishable_key_forbidden": + throw new CliError( + "Billing needs a secret live key (pv_live_...); publishable keys are not allowed.", + 1 + ); + case "email_not_verified": + throw new CliError( + "Verify your email before upgrading (check your inbox for the code).", + 1 + ); + case "already_subscribed": + throw new CliError( + "You already have a subscription. Change or cancel it with `pixelvault billing`.", + 1 + ); + case "no_subscription": + throw new CliError( + "This account has no billing history yet. Start one with `pixelvault upgrade `.", + 1 + ); + case "billing_unavailable": + throw new CliError("Paid plans are not available right now. Try again later.", 1); + case "rate_limit_exceeded": { + const wait = err.retryAfter ? ` Retry after ${err.retryAfter} seconds.` : ""; + throw new CliError( + `Rate limit exceeded (10 billing requests per hour per account).${wait}`, + 1 + ); + } + } + } + throw err; +} + +export default defineCommand({ + meta: { + name: "billing", + description: + "Print a Stripe billing-portal URL to change plan, update payment details or cancel (live key required)", + }, + args: { + json: { + type: "boolean", + description: "Output { url } as JSON", + default: false, + }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + let res: { url: string }; + try { + res = await apiRequest<{ url: string }>({ + method: "POST", + path: "/v1/billing/portal", + body: {}, + auth: apiKey, + }); + } catch (err) { + explainBillingError(err); + } + + if (args.json) { + jsonOut({ url: res.url }); + } else { + stdout(res.url); + stderr( + "Open this link yourself to change plan, update payment details or cancel. It can cancel the subscription, so don't paste it anywhere shared." + ); + } + }, +}); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts new file mode 100644 index 0000000..2b93a68 --- /dev/null +++ b/src/commands/upgrade.ts @@ -0,0 +1,55 @@ +import { defineCommand } from "citty"; +import { apiRequest } from "../lib/client.js"; +import { requireApiKey } from "../lib/config.js"; +import { CliError } from "../lib/errors.js"; +import { stdout, stderr, jsonOut } from "../lib/output.js"; +import { explainBillingError } from "./billing.js"; + +const PLANS = ["starter", "pro"] as const; + +export default defineCommand({ + meta: { + name: "upgrade", + description: + "Print a Stripe Checkout URL to subscribe to a paid plan (live key required; nothing is charged until you pay)", + }, + args: { + plan: { + type: "positional", + description: `Plan to subscribe to: ${PLANS.join(" | ")}`, + required: true, + }, + json: { + type: "boolean", + description: "Output { url } as JSON", + default: false, + }, + }, + async run({ args }) { + const plan = String(args.plan); + if (!(PLANS as readonly string[]).includes(plan)) { + throw new CliError(`Unknown plan "${plan}". Choose one of: ${PLANS.join(", ")}.`, 1); + } + const apiKey = requireApiKey(); + let res: { url: string }; + try { + res = await apiRequest<{ url: string }>({ + method: "POST", + path: "/v1/billing/checkout", + body: { plan }, + auth: apiKey, + }); + } catch (err) { + explainBillingError(err); + } + + if (args.json) { + jsonOut({ url: res.url }); + } else { + stdout(res.url); + stderr( + "Open this link to complete payment. The plan changes once Stripe confirms — check with `pixelvault usage`." + ); + } + }, +}); diff --git a/src/index.ts b/src/index.ts index a2695e3..3463801 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ const main = defineCommand({ export: () => import("./commands/export.js").then((m) => m.default), usage: () => import("./commands/usage.js").then((m) => m.default), keys: () => import("./commands/keys.js").then((m) => m.default), + upgrade: () => import("./commands/upgrade.js").then((m) => m.default), + billing: () => import("./commands/billing.js").then((m) => m.default), whoami: () => import("./commands/whoami.js").then((m) => m.default), config: () => import("./commands/config.js").then((m) => m.default), }, diff --git a/tests/commands/upgrade-billing.test.ts b/tests/commands/upgrade-billing.test.ts new file mode 100644 index 0000000..89dc19f --- /dev/null +++ b/tests/commands/upgrade-billing.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/lib/config.js", () => ({ + requireApiKey: () => "pv_live_test", + getApiUrl: () => "https://api.test.pixelvault.dev", +})); + +const { default: upgrade } = await import("../../src/commands/upgrade.js"); +const { default: billing } = await import("../../src/commands/billing.js"); + +type Cmd = { run?: (ctx: any) => Promise }; + +function mockFetch(status: number, body: unknown, headers: Record = {}) { + const f = vi.fn().mockResolvedValue({ + ok: status < 400, + status, + statusText: "x", + headers: new Headers(headers), + json: () => Promise.resolve(body), + }); + globalThis.fetch = f as any; + return f; +} +const apiErr = (code: string, message = "msg") => ({ error: { code, message } }); + +describe("upgrade / billing commands", () => { + const originalFetch = globalThis.fetch; + let out = ""; + let err = ""; + + beforeEach(() => { + out = ""; + err = ""; + vi.spyOn(process.stdout, "write").mockImplementation((s: any) => { + out += s; + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((s: any) => { + err += s; + return true; + }); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("upgrade", () => { + it("POSTs the plan and prints only the URL to stdout, guidance to stderr", async () => { + const f = mockFetch(200, { url: "https://checkout.stripe.com/c/1" }); + await (upgrade as Cmd).run!({ args: { plan: "pro", json: false } }); + const [url, init] = f.mock.calls[0]; + expect(url).toBe("https://api.test.pixelvault.dev/v1/billing/checkout"); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("Bearer pv_live_test"); + expect(JSON.parse(init.body)).toEqual({ plan: "pro" }); + expect(out).toBe("https://checkout.stripe.com/c/1\n"); + expect(err).toContain("pixelvault usage"); + }); + + it("--json prints { url }", async () => { + mockFetch(200, { url: "https://checkout.stripe.com/c/1" }); + await (upgrade as Cmd).run!({ args: { plan: "starter", json: true } }); + expect(JSON.parse(out)).toEqual({ url: "https://checkout.stripe.com/c/1" }); + }); + + it("rejects an unknown plan before any request", async () => { + const f = mockFetch(200, {}); + await expect((upgrade as Cmd).run!({ args: { plan: "enterprise" } })).rejects.toThrow( + /Unknown plan/ + ); + expect(f).not.toHaveBeenCalled(); + }); + + it.each([ + ["test_key_forbidden", 403, /live key/], + ["publishable_key_forbidden", 403, /publishable keys are not allowed/], + ["email_not_verified", 403, /Verify your email/], + ["already_subscribed", 409, /pixelvault billing/], + ["billing_unavailable", 503, /not available/], + ])("explains %s", async (code, status, re) => { + mockFetch(status, apiErr(code)); + await expect( + (upgrade as Cmd).run!({ args: { plan: "starter", json: false } }) + ).rejects.toThrow(re); + expect(out).toBe(""); + }); + + it("explains the rate limit with Retry-After", async () => { + mockFetch(429, apiErr("rate_limit_exceeded"), { "Retry-After": "120" }); + await expect( + (upgrade as Cmd).run!({ args: { plan: "starter", json: false } }) + ).rejects.toThrow(/Retry after 120 seconds/); + }); + }); + + describe("billing", () => { + it("POSTs to the portal and prints only the URL", async () => { + const f = mockFetch(200, { url: "https://billing.stripe.com/p/1" }); + await (billing as Cmd).run!({ args: { json: false } }); + expect(f.mock.calls[0][0]).toBe("https://api.test.pixelvault.dev/v1/billing/portal"); + expect(out).toBe("https://billing.stripe.com/p/1\n"); + expect(err).toContain("cancel"); + }); + + it("explains no_subscription", async () => { + mockFetch(404, apiErr("no_subscription")); + await expect((billing as Cmd).run!({ args: { json: false } })).rejects.toThrow( + /pixelvault upgrade/ + ); + }); + }); +}); From 1ea891420e010d932e3ae9fea393d70aff893b87 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 28 Oct 2026 09:52:51 +0100 Subject: [PATCH 2/2] chore: release 0.7.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- src/lib/client.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b86545..c81a63d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixelvault", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixelvault", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "citty": "^0.1.6" diff --git a/package.json b/package.json index ef8f384..86536f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixelvault-cli", - "version": "0.6.1", + "version": "0.7.0", "description": "CLI for PixelVault — agent-first image hosting", "type": "module", "bin": { diff --git a/src/lib/client.ts b/src/lib/client.ts index 2c3d716..f62b3c5 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -17,7 +17,7 @@ interface ApiError { // this channel (server reads X-PixelVault-Client at register/device-login — see // pixelvault docs/specs/lead-attribution.md). Keep the version in sync with // package.json on release (tests/lib/version.test.ts fails if they drift). -export const VERSION = "0.6.1"; +export const VERSION = "0.7.0"; const CLIENT_ID = `pixelvault-cli/${VERSION}`; /** CliError carrying the API's machine-readable error details. */