diff --git a/README.md b/README.md index ab6f455..de828e8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ pixelvault get img_xyz -o thumb.webp -t "w=400&fmt=webp" # download a transfor # Delete an image pixelvault delete img_xyz +# Check quota before a bulk upload +pixelvault usage + +# Manage API keys (secret key required) +pixelvault keys list +KEY=$(pixelvault keys create --label ci) # raw key on stdout, shown once +pixelvault keys revoke key_abc123 + # Export everything — all your images as a tar archive (no lock-in) pixelvault export -o my-images.tar ``` @@ -84,6 +92,8 @@ npx pixelvault-cli upload build-output.png | `get ` | Get an image's URL/metadata, or download it (optionally transformed) | | `delete ` | Delete an image | | `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 | | `whoami` | Show current auth state | | `config get\|set\|show` | Manage CLI configuration | @@ -135,6 +145,32 @@ pixelvault list --json # Full JSON with metadata pixelvault list --page 2 --per-page 50 # Pagination ``` +### Usage and Keys + +Both need a **secret** key (`pv_live_...`); publishable keys get a 403. + +```bash +pixelvault usage # plan, period, used / limit / remaining +pixelvault usage --json # the API `data` object + +pixelvault keys list # id, type, env, prefix, label, last used +pixelvault keys create --label ci --env live +pixelvault keys revoke key_abc123 +``` + +- `usage` reports storage and uploads against your limits; bandwidth shows + only its monthly limit (usage is not reported). +- `keys create` prints **only the raw key** to stdout (the "shown once" warning + goes to stderr), so `KEY=$(pixelvault keys create)` works. The key is never + shown again: store it in a secret manager and never commit it or log it. + `--json` prints the whole data object instead. +- Limits: max 25 keys per project; 20 key changes per hour (429 includes + `Retry-After`). Only secret keys can be revoked, and the key making the + request cannot revoke itself. +- **Rotate a key**: `NEW=$(pixelvault keys create --label rotated)`, switch + `PIXELVAULT_API_KEY` to `$NEW`, then `pixelvault keys revoke ` using + the new key. If you get locked out, use the dashboard's Regenerate. + ## Configuration Config is stored at `~/.pixelvault/config.json` (0600 permissions). diff --git a/package-lock.json b/package-lock.json index 1709352..83f5244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixelvault", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixelvault", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "citty": "^0.1.6" diff --git a/package.json b/package.json index 9a2b792..88337ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixelvault-cli", - "version": "0.5.2", + "version": "0.6.0", "description": "CLI for PixelVault — agent-first image hosting", "type": "module", "bin": { diff --git a/src/commands/keys.ts b/src/commands/keys.ts new file mode 100644 index 0000000..1c4de0d --- /dev/null +++ b/src/commands/keys.ts @@ -0,0 +1,198 @@ +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"; + +export interface KeyInfo { + id: string; + type: "secret" | "publishable"; + environment: string; + label: string | null; + key_prefix: string; + allowed_origins: string[] | null; + created_at: string; + last_used_at: string | null; + current: boolean; +} + +/** Turn known key-management API errors into actionable messages. */ +export function explainKeyError(err: unknown): never { + if (err instanceof ApiRequestError) { + switch (err.code) { + case "key_limit_reached": + throw new CliError( + "Key limit reached (25 per project). Revoke an unused key with `pixelvault keys revoke ` first.", + 1 + ); + case "rate_limit_exceeded": { + const wait = err.retryAfter + ? ` Retry after ${err.retryAfter} seconds.` + : ""; + throw new CliError( + `Rate limit exceeded (20 key changes per hour per project).${wait}`, + 1 + ); + } + case "cannot_revoke_current_key": + throw new CliError( + "Cannot revoke the key you are authenticating with. Create a new key, switch to it (PIXELVAULT_API_KEY), then revoke the old one with the new key.", + 1 + ); + case "not_found": + throw new CliError( + "Key not found. Only secret keys can be revoked; check the id with `pixelvault keys list`.", + 1 + ); + case "validation_error": + throw new CliError(`Invalid request: ${err.message}`, 1); + case "forbidden": + throw new CliError( + "This endpoint needs a secret key (pv_live_...); publishable keys are not allowed.", + 1 + ); + } + if (err.status === 403) { + throw new CliError( + "This endpoint needs a secret key (pv_live_...); publishable keys are not allowed.", + 1 + ); + } + } + throw err; +} + +const list = defineCommand({ + meta: { name: "list", description: "List the project's API keys" }, + args: { + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + let res: { data: KeyInfo[] }; + try { + res = await apiRequest<{ data: KeyInfo[] }>({ + path: "/v1/keys", + auth: apiKey, + }); + } catch (err) { + explainKeyError(err); + } + + if (args.json) { + jsonOut(res.data); + return; + } + for (const k of res.data) { + stdout( + [ + k.id, + k.type, + k.environment, + `${k.key_prefix}...`, + k.label ?? "-", + `created ${k.created_at}`, + `last used ${k.last_used_at ?? "never"}`, + k.current ? "(current)" : "", + ] + .filter(Boolean) + .join(" ") + ); + } + }, +}); + +const create = defineCommand({ + meta: { + name: "create", + description: "Create a new secret API key (the raw key is printed once)", + }, + args: { + label: { type: "string", description: "Label for the key (max 60 chars)" }, + env: { + type: "string", + description: "Environment: live or test (default: live)", + }, + json: { + type: "boolean", + description: "Output the full data object as JSON", + default: false, + }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + if (args.env && args.env !== "live" && args.env !== "test") { + throw new CliError("--env must be 'live' or 'test'.", 1); + } + if (args.label && args.label.length > 60) { + throw new CliError("--label must be 60 characters or fewer.", 1); + } + + const body: { label?: string; environment?: string } = {}; + if (args.label) body.label = args.label; + if (args.env) body.environment = args.env; + + let res: { data: { id: string; api_key: string } }; + try { + res = await apiRequest<{ data: { id: string; api_key: string } }>({ + method: "POST", + path: "/v1/keys", + auth: apiKey, + body, + }); + } catch (err) { + explainKeyError(err); + } + + stderr( + "This key is shown ONCE. Store it now (secret manager / env var); never commit it or paste it into logs." + ); + if (args.json) { + jsonOut(res.data); + } else { + stdout(res.data.api_key); + } + }, +}); + +const revoke = defineCommand({ + meta: { + name: "revoke", + description: "Revoke a secret API key by id (not the one in use)", + }, + args: { + id: { + type: "positional", + description: "Key id (see `pixelvault keys list`)", + required: true, + }, + json: { type: "boolean", description: "Output as JSON", default: false }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + let res: { data: { deleted: boolean } }; + try { + res = await apiRequest<{ data: { deleted: boolean } }>({ + method: "DELETE", + path: `/v1/keys/${encodeURIComponent(args.id)}`, + auth: apiKey, + }); + } catch (err) { + explainKeyError(err); + } + + if (args.json) { + jsonOut(res.data); + } else { + stderr(`Revoked key ${args.id}.`); + } + }, +}); + +export default defineCommand({ + meta: { + name: "keys", + description: "Manage API keys (list, create, revoke)", + }, + subCommands: { list, create, revoke }, +}); diff --git a/src/commands/usage.ts b/src/commands/usage.ts new file mode 100644 index 0000000..dbeaff1 --- /dev/null +++ b/src/commands/usage.ts @@ -0,0 +1,59 @@ +import { defineCommand } from "citty"; +import { apiRequest } from "../lib/client.js"; +import { requireApiKey } from "../lib/config.js"; +import { stdout, jsonOut, formatBytes } from "../lib/output.js"; + +export interface UsageData { + plan: string; + period: string; + resets_at: string; + usage: { storage_bytes: number; uploads: number }; + limits: { + storage_bytes: number | null; + uploads_per_month: number | null; + bandwidth_bytes_per_month: number | null; + }; + remaining: { storage_bytes: number | null; uploads: number | null }; +} + +const bytes = (n: number | null | undefined) => + n == null ? "unlimited" : formatBytes(n); +const count = (n: number | null | undefined) => + n == null ? "unlimited" : String(n); + +export function renderUsage(d: UsageData): string[] { + return [ + `Plan: ${d.plan}`, + `Period: ${d.period} (resets ${d.resets_at})`, + `Storage: ${formatBytes(d.usage.storage_bytes)} used / ${bytes(d.limits.storage_bytes)} limit / ${bytes(d.remaining.storage_bytes)} remaining`, + `Uploads: ${d.usage.uploads} used / ${count(d.limits.uploads_per_month)} limit / ${count(d.remaining.uploads)} remaining`, + `Bandwidth: ${bytes(d.limits.bandwidth_bytes_per_month)} limit per month (usage not reported)`, + ]; +} + +export default defineCommand({ + meta: { + name: "usage", + description: "Show plan, quota usage and limits (secret key required)", + }, + args: { + json: { + type: "boolean", + description: "Output the usage object as JSON", + default: false, + }, + }, + async run({ args }) { + const apiKey = requireApiKey(); + const res = await apiRequest<{ data: UsageData }>({ + path: "/v1/usage", + auth: apiKey, + }); + + if (args.json) { + jsonOut(res.data); + } else { + for (const line of renderUsage(res.data)) stdout(line); + } + }, +}); diff --git a/src/index.ts b/src/index.ts index ecd4f7b..83550fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ const main = defineCommand({ get: () => import("./commands/get.js").then((m) => m.default), delete: () => import("./commands/delete.js").then((m) => m.default), 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), whoami: () => import("./commands/whoami.js").then((m) => m.default), config: () => import("./commands/config.js").then((m) => m.default), }, diff --git a/src/lib/client.ts b/src/lib/client.ts index 72e1dd9..8ec1c0f 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -17,7 +17,27 @@ 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. -const CLIENT_ID = "pixelvault-cli/0.5.2"; +const CLIENT_ID = "pixelvault-cli/0.6.0"; + +/** CliError carrying the API's machine-readable error details. */ +export class ApiRequestError extends CliError { + status: number; + code?: string; + retryAfter?: string; + + constructor( + message: string, + status: number, + code?: string, + retryAfter?: string + ) { + super(message, 1); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.retryAfter = retryAfter; + } +} export async function apiRequest( options: RequestOptions & { auth?: string } @@ -68,7 +88,12 @@ export async function apiRequest( const message = errorBody?.error?.message || `HTTP ${res.status}: ${res.statusText}`; - throw new CliError(message, 1); + throw new ApiRequestError( + message, + res.status, + errorBody?.error?.code, + res.headers?.get?.("Retry-After") ?? undefined + ); } return (await res.json()) as T; diff --git a/src/lib/output.ts b/src/lib/output.ts index 47f2016..9fef1c3 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -26,3 +26,18 @@ export function fatal(message: string, exitCode = 1): never { stderr(`Error: ${message}`); process.exit(exitCode); } + +/** + * Format a byte count for humans (1024-based). + */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 100 || Number.isInteger(v) ? 0 : 1)} ${units[i]}`; +} diff --git a/tests/commands/usage-keys.test.ts b/tests/commands/usage-keys.test.ts new file mode 100644 index 0000000..6948808 --- /dev/null +++ b/tests/commands/usage-keys.test.ts @@ -0,0 +1,231 @@ +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: usage } = await import("../../src/commands/usage.js"); +const { default: keys } = await import("../../src/commands/keys.js"); +const { formatBytes } = await import("../../src/lib/output.js"); + +type Cmd = { run?: (ctx: any) => unknown; subCommands?: any }; +const sub = (n: string) => (keys as Cmd).subCommands[n] as Cmd; + +const USAGE = { + plan: "free", + period: "2026-10", + resets_at: "2026-11-01T00:00:00Z", + usage: { storage_bytes: 5 * 1024 * 1024, uploads: 12 }, + limits: { + storage_bytes: 1024 ** 3, + uploads_per_month: 500, + bandwidth_bytes_per_month: 10 * 1024 ** 3, + }, + remaining: { storage_bytes: 1024 ** 3 - 5 * 1024 * 1024, uploads: 488 }, +}; + +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("usage / keys 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(); + }); + + it("formatBytes", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1024 ** 3)).toBe("1 GB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + }); + + describe("usage", () => { + it("prints human-readable summary to stdout", async () => { + const f = mockFetch(200, { data: USAGE }); + await usage.run!({ args: { json: false } } as any); + expect(f.mock.calls[0][0]).toBe("https://api.test.pixelvault.dev/v1/usage"); + expect(out).toContain("Plan: free"); + expect(out).toContain("2026-10"); + expect(out).toContain("5 MB used / 1 GB limit"); + expect(out).toContain("12 used / 500 limit / 488 remaining"); + expect(out).toContain("10 GB limit per month"); + expect(err).toBe(""); + }); + + it("--json prints the data object", async () => { + mockFetch(200, { data: USAGE }); + await usage.run!({ args: { json: true } } as any); + expect(JSON.parse(out)).toEqual(USAGE); + }); + + it("rejects with the API message on 403", async () => { + mockFetch(403, apiErr("forbidden", "Secret key required")); + await expect(usage.run!({ args: { json: false } } as any)).rejects.toThrow( + "Secret key required", + ); + expect(out).toBe(""); + }); + }); + + describe("keys list", () => { + const KEYS = [ + { + id: "key_1", + type: "secret", + environment: "live", + label: "ci", + key_prefix: "pv_live_ab12", + allowed_origins: null, + created_at: "2026-10-01T00:00:00Z", + last_used_at: null, + current: true, + }, + ]; + + it("prints one line per key, no raw secrets", async () => { + mockFetch(200, { data: KEYS }); + await sub("list").run!({ args: { json: false } }); + expect(out).toContain("key_1"); + expect(out).toContain("pv_live_ab12..."); + expect(out).toContain("last used never"); + expect(out).toContain("(current)"); + expect(err).toBe(""); + }); + + it("--json prints the array", async () => { + mockFetch(200, { data: KEYS }); + await sub("list").run!({ args: { json: true } }); + expect(JSON.parse(out)).toEqual(KEYS); + }); + + it("explains publishable-key 403", async () => { + mockFetch(403, apiErr("forbidden")); + await expect(sub("list").run!({ args: {} })).rejects.toThrow(/secret key/); + }); + }); + + describe("keys create", () => { + const CREATED = { + id: "key_2", + api_key: "pv_live_SECRETVALUE", + type: "secret", + environment: "live", + label: "ci", + created_at: "2026-10-24T00:00:00Z", + }; + + it("prints ONLY the raw key on stdout; warning on stderr", async () => { + const f = mockFetch(201, { data: CREATED }); + await sub("create").run!({ args: { label: "ci", env: "live", json: false } }); + expect(out).toBe("pv_live_SECRETVALUE\n"); + expect(err).toMatch(/shown ONCE/); + expect(err).not.toContain("SECRETVALUE"); + const init = f.mock.calls[0][1]; + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ label: "ci", environment: "live" }); + }); + + it("sends an empty body when no options given", async () => { + const f = mockFetch(201, { data: CREATED }); + await sub("create").run!({ args: { json: false } }); + expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({}); + }); + + it("--json prints whole data object", async () => { + mockFetch(201, { data: CREATED }); + await sub("create").run!({ args: { json: true } }); + expect(JSON.parse(out)).toEqual(CREATED); + expect(err).toMatch(/shown ONCE/); + }); + + it("rejects bad --env and long --label without calling the API", async () => { + const f = mockFetch(201, { data: CREATED }); + await expect(sub("create").run!({ args: { env: "prod" } })).rejects.toThrow(/--env/); + await expect( + sub("create").run!({ args: { label: "x".repeat(61) } }), + ).rejects.toThrow(/60/); + expect(f).not.toHaveBeenCalled(); + }); + + it("maps 400 validation_error", async () => { + mockFetch(400, apiErr("validation_error", "label too long")); + await expect(sub("create").run!({ args: {} })).rejects.toThrow(/Invalid request: label too long/); + expect(out).toBe(""); + }); + + it("maps 409 key_limit_reached", async () => { + mockFetch(409, apiErr("key_limit_reached")); + await expect(sub("create").run!({ args: {} })).rejects.toThrow(/25 per project/); + }); + + it("maps 429 with Retry-After", async () => { + mockFetch(429, apiErr("rate_limit_exceeded"), { "Retry-After": "120" }); + await expect(sub("create").run!({ args: {} })).rejects.toThrow( + /20 key changes per hour.*Retry after 120 seconds/, + ); + }); + }); + + describe("keys revoke", () => { + it("is quiet on stdout, confirms on stderr", async () => { + const f = mockFetch(200, { data: { deleted: true } }); + await sub("revoke").run!({ args: { id: "key_9", json: false } }); + expect(f.mock.calls[0][0]).toBe("https://api.test.pixelvault.dev/v1/keys/key_9"); + expect(f.mock.calls[0][1].method).toBe("DELETE"); + expect(out).toBe(""); + expect(err).toContain("Revoked key key_9"); + }); + + it("--json prints data", async () => { + mockFetch(200, { data: { deleted: true } }); + await sub("revoke").run!({ args: { id: "key_9", json: true } }); + expect(JSON.parse(out)).toEqual({ deleted: true }); + }); + + it("maps 404 not_found", async () => { + mockFetch(404, apiErr("not_found")); + await expect(sub("revoke").run!({ args: { id: "pk" } })).rejects.toThrow( + /Only secret keys can be revoked/, + ); + }); + + it("maps 409 cannot_revoke_current_key", async () => { + mockFetch(409, apiErr("cannot_revoke_current_key")); + await expect(sub("revoke").run!({ args: { id: "k" } })).rejects.toThrow( + /Create a new key, switch to it/, + ); + }); + + it("maps 429", async () => { + mockFetch(429, apiErr("rate_limit_exceeded")); + await expect(sub("revoke").run!({ args: { id: "k" } })).rejects.toThrow(/Rate limit/); + }); + }); +});