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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -84,6 +92,8 @@ npx pixelvault-cli upload build-output.png
| `get <id>` | Get an image's URL/metadata, or download it (optionally transformed) |
| `delete <id>` | 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 |

Expand Down Expand Up @@ -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 <old-id>` using
the new key. If you get locked out, use the dashboard's Regenerate.

## 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.5.2",
"version": "0.6.0",
"description": "CLI for PixelVault — agent-first image hosting",
"type": "module",
"bin": {
Expand Down
198 changes: 198 additions & 0 deletions src/commands/keys.ts
Original file line number Diff line number Diff line change
@@ -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 <id>` 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 },
});
59 changes: 59 additions & 0 deletions src/commands/usage.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
29 changes: 27 additions & 2 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
options: RequestOptions & { auth?: string }
Expand Down Expand Up @@ -68,7 +88,12 @@ export async function apiRequest<T>(

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;
Expand Down
Loading
Loading