Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <starter\|pro>` | 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 |

Expand Down Expand Up @@ -171,6 +173,19 @@ pixelvault keys revoke key_abc123
`PIXELVAULT_API_KEY` to `$NEW`, then `pixelvault keys revoke <old-id>` 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).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
86 changes: 86 additions & 0 deletions src/commands/billing.ts
Original file line number Diff line number Diff line change
@@ -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 <starter|pro>`.",
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."
);
}
},
});
55 changes: 55 additions & 0 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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`."
);
}
},
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
2 changes: 1 addition & 1 deletion src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
113 changes: 113 additions & 0 deletions tests/commands/upgrade-billing.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> };

function mockFetch(status: number, body: unknown, headers: Record<string, string> = {}) {
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/
);
});
});
});
Loading