diff --git a/README.md b/README.md index f6a0092..ea85a6d 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -70,6 +75,7 @@ npx pixelvault-cli upload build-output.png | `login` | Log in to existing account | | `upload ` | Upload images (prints URLs to stdout) | | `list` | List uploaded images | +| `get ` | Get an image's URL/metadata, or download it (optionally transformed) | | `delete ` | Delete an image | | `whoami` | Show current auth state | | `config get\|set\|show` | Manage CLI configuration | @@ -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 + — 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 diff --git a/src/commands/get.ts b/src/commands/get.ts new file mode 100644 index 0000000..0d5625b --- /dev/null +++ b/src/commands/get.ts @@ -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({ + 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); + } + }, +}); diff --git a/src/index.ts b/src/index.ts index 45541b0..498e297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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), diff --git a/tests/commands/get.test.ts b/tests/commands/get.test.ts new file mode 100644 index 0000000..88524ce --- /dev/null +++ b/tests/commands/get.test.ts @@ -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", + ); + }); +});