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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ pixelvault upload *.png --folder screenshots
# List your images
pixelvault list

# Get an image's URL, or download it (optionally transformed)
pixelvault get img_xyz # prints the CDN URL
pixelvault get img_xyz -o photo.png # download the original
pixelvault get img_xyz -o thumb.webp -t "w=400&fmt=webp" # download a transformed variant

# Delete an image
pixelvault delete img_xyz
```
Expand Down Expand Up @@ -70,6 +75,7 @@ npx pixelvault-cli upload build-output.png
| `login` | Log in to existing account |
| `upload <files...>` | Upload images (prints URLs to stdout) |
| `list` | List uploaded images |
| `get <id>` | Get an image's URL/metadata, or download it (optionally transformed) |
| `delete <id>` | Delete an image |
| `whoami` | Show current auth state |
| `config get\|set\|show` | Manage CLI configuration |
Expand All @@ -82,6 +88,22 @@ pixelvault upload *.png --folder icons # Bulk with folder
pixelvault upload shot.png --json # Full JSON response
```

### Get Options

```bash
pixelvault get img_xyz # print the CDN URL to stdout
pixelvault get img_xyz --json # full metadata
pixelvault get img_xyz -o photo.png # download the original to a file
pixelvault get img_xyz -o cut.png -t "segment=foreground" # download a transparent cut-out
pixelvault get img_xyz -t "w=400&fmt=webp" # just print the transformed URL
```

Transform params (`-t`/`--transform`) are the same URL params documented at
<https://pixelvault.dev/docs#transforms> — resize (`w`/`h`/`fit`), format (`fmt`),
background removal (`segment=foreground`), effects (`blur`/`saturation`/`rotate`/…),
and watermark (`tile=img_logo.png` — another of your own images). Reserved
characters in values are percent-encoded for you (e.g. `background=#ffaa00`).

### List Options

```bash
Expand Down
128 changes: 128 additions & 0 deletions src/commands/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { defineCommand } from "citty";
import { writeFileSync } from "node:fs";
import { apiRequest } from "../lib/client.js";
import { requireApiKey } from "../lib/config.js";
import { stdout, stderr, jsonOut } from "../lib/output.js";
import { CliError } from "../lib/errors.js";

interface GetResponse {
data: {
id: string;
url: string;
mime_type: string;
size: number;
filename: string;
folder: string | null;
created_at: string;
};
}

/**
* Append a transform query string to a CDN URL. Accepts params with or without a
* leading `?`/`&` (e.g. `w=400&fmt=webp`, `?segment=foreground`). See the transform
* docs: https://pixelvault.dev/docs#transforms
*
* The params are reserialized through URLSearchParams so reserved characters in
* values are percent-encoded — without this a hex color like `background=#ffaa00`
* would be parsed as a URL fragment and lost. Idempotent for already-encoded input.
*/
export function applyTransform(url: string, transform?: string): string {
if (!transform) return url;
const raw = transform.trim().replace(/^[?&]+/, "");
if (!raw) return url;
const params = new URLSearchParams(raw).toString();
if (!params) return url;
return url.includes("?") ? `${url}&${params}` : `${url}?${params}`;
}

export default defineCommand({
meta: {
name: "get",
description:
"Get an image's CDN URL or metadata, or download it — optionally transformed",
},
args: {
id: {
type: "positional",
description: "Image ID (e.g. img_abc123)",
required: true,
},
output: {
type: "string",
description: "Download the image to this file path",
alias: "o",
},
transform: {
type: "string",
description:
'Transform params to apply, e.g. "w=400&fmt=webp", "segment=foreground", "tile=img_logo.png"',
alias: "t",
},
json: {
type: "boolean",
description: "Output full JSON metadata",
default: false,
},
},
async run({ args }) {
const apiKey = requireApiKey();

const res = await apiRequest<GetResponse>({
path: `/v1/images/${encodeURIComponent(args.id)}`,
auth: apiKey,
});

const url = applyTransform(res.data.url, args.transform as string | undefined);

// Download mode: fetch the (possibly transformed) bytes to disk.
if (args.output) {
const output = args.output as string;
let bytes: ArrayBuffer;
try {
const dl = await fetch(url);
if (!dl.ok) {
throw new CliError(`Failed to download image: HTTP ${dl.status}`, 1);
}
bytes = await dl.arrayBuffer();
} catch (err) {
if (err instanceof CliError) throw err;
throw new CliError(
`Failed to download image: ${err instanceof Error ? err.message : String(err)}`,
1,
);
}
// Refuse to write an empty file — a 200 with an empty/truncated body would
// otherwise report success and leave a broken image that agents chain on.
if (bytes.byteLength === 0) {
throw new CliError("Downloaded 0 bytes — refusing to write an empty file", 1);
}
try {
writeFileSync(output, Buffer.from(bytes));
} catch (err) {
throw new CliError(
`Failed to save ${output}: ${err instanceof Error ? err.message : String(err)}`,
1,
);
}
stderr(`Saved ${output}`);
// Honor --json even in download mode so a caller that always requests JSON
// gets a stable stdout contract regardless of -o.
if (args.json) {
jsonOut(
args.transform ? { ...res, transformed_url: url, output } : { ...res, output },
);
} else {
stdout(url); // URL to stdout (machine-parseable), confirmation to stderr
}
return;
}

if (args.json) {
// When a transform is applied there's no separate record for it, so surface
// the resolved URL alongside the raw metadata.
jsonOut(args.transform ? { ...res, transformed_url: url } : res);
} else {
stdout(url);
}
},
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const main = defineCommand({
login: () => import("./commands/login.js").then((m) => m.default),
upload: () => import("./commands/upload.js").then((m) => m.default),
list: () => import("./commands/list.js").then((m) => m.default),
get: () => import("./commands/get.js").then((m) => m.default),
delete: () => import("./commands/delete.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
79 changes: 79 additions & 0 deletions tests/commands/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { applyTransform } from "../../src/commands/get.js";

// Mock config so imports resolve without a real key/URL.
vi.mock("../../src/lib/config.js", () => ({
requireApiKey: () => "pv_live_test",
getApiUrl: () => "https://api.test.pixelvault.dev",
}));

describe("get command", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});

describe("applyTransform", () => {
const U = "https://img.pixelvault.dev/proj/img.png";

it("returns the URL unchanged when no transform is given", () => {
expect(applyTransform(U)).toBe(U);
expect(applyTransform(U, "")).toBe(U);
expect(applyTransform(U, " ")).toBe(U); // whitespace-only → no-op
});

it("appends params with ? when the URL has no query", () => {
expect(applyTransform(U, "w=400&fmt=webp")).toBe(`${U}?w=400&fmt=webp`);
});

it("appends with & when the URL already has a query", () => {
expect(applyTransform(`${U}?v=2`, "segment=foreground")).toBe(
`${U}?v=2&segment=foreground`,
);
});

it("tolerates a leading ? or & in the transform string", () => {
expect(applyTransform(U, "?tile=img_logo.png")).toBe(`${U}?tile=img_logo.png`);
});

it("percent-encodes reserved chars so hex colors aren't lost as a fragment", () => {
// Without encoding, `#ffaa00` would be parsed as a URL fragment → background lost.
expect(applyTransform(U, "segment=foreground&background=#ffaa00")).toBe(
`${U}?segment=foreground&background=%23ffaa00`,
);
});

it("encodes a folder-prefixed tile path", () => {
expect(applyTransform(U, "tile=blog/logo.png")).toBe(`${U}?tile=blog%2Flogo.png`);
});
});

it("fetches image metadata by id and exposes the CDN URL", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
id: "img_abc123",
url: "https://img.pixelvault.dev/proj_123/img_abc123.png",
mime_type: "image/png",
size: 2048,
filename: "photo.png",
folder: null,
created_at: "2026-07-07T00:00:00Z",
},
}),
});

const { apiRequest } = await import("../../src/lib/client.js");
const res = await apiRequest<{ data: { url: string; id: string } }>({
path: "/v1/images/img_abc123",
auth: "pv_live_test",
});

expect(res.data.id).toBe("img_abc123");
expect(applyTransform(res.data.url, "w=400")).toBe(
"https://img.pixelvault.dev/proj_123/img_abc123.png?w=400",
);
});
});
Loading