From 7ad0967bec626d8d121cbd7042f55a0b3733103c Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Tue, 7 Jul 2026 17:16:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(get):=20retrieve=20an=20image=20?= =?UTF-8?q?=E2=80=94=20URL,=20metadata,=20or=20download=20(optionally=20tr?= =?UTF-8?q?ansformed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI could upload/list/delete but had no way to RETRIEVE an image's bytes. Adds `pixelvault get `: - prints the CDN URL to stdout (agent-friendly, mirrors `list`) - `--json` → full metadata - `-o/--output ` → download the image to disk - `-t/--transform ""` → apply transform params (resize/format/ segment/effects/tile) to the URL or downloaded variant, e.g. `get img_x -o cut.png -t "segment=foreground"` Output contract preserved: URL to stdout, human messages to stderr. `applyTransform` tolerates a leading ?/&. Tests: applyTransform + metadata fetch (16 total). README + command table updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 21 ++++++++ src/commands/get.ts | 101 +++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + tests/commands/get.test.ts | 70 +++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 src/commands/get.ts create mode 100644 tests/commands/get.test.ts diff --git a/README.md b/README.md index f6a0092..cfb3c8e 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,21 @@ 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=`). + ### List Options ```bash diff --git a/src/commands/get.ts b/src/commands/get.ts new file mode 100644 index 0000000..9276a66 --- /dev/null +++ b/src/commands/get.ts @@ -0,0 +1,101 @@ +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 + */ +export function applyTransform(url: string, transform?: string): string { + if (!transform) return url; + const q = transform.replace(/^[?&]+/, ""); + if (!q) return url; + return url.includes("?") ? `${url}&${q}` : `${url}?${q}`; +} + +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=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/${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) { + 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, + ); + } + writeFileSync(args.output as string, Buffer.from(bytes)); + stderr(`Saved ${args.output}`); + 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..4fc3c8f --- /dev/null +++ b/tests/commands/get.test.ts @@ -0,0 +1,70 @@ +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", () => { + it("returns the URL unchanged when no transform is given", () => { + const u = "https://img.pixelvault.dev/proj/img.png"; + expect(applyTransform(u)).toBe(u); + expect(applyTransform(u, "")).toBe(u); + }); + + it("appends params with ? when the URL has no query", () => { + expect( + applyTransform("https://img.pixelvault.dev/proj/img.png", "w=400&fmt=webp"), + ).toBe("https://img.pixelvault.dev/proj/img.png?w=400&fmt=webp"); + }); + + it("appends with & when the URL already has a query", () => { + expect( + applyTransform("https://img.pixelvault.dev/proj/img.png?v=2", "segment=foreground"), + ).toBe("https://img.pixelvault.dev/proj/img.png?v=2&segment=foreground"); + }); + + it("tolerates a leading ? or & in the transform string", () => { + expect( + applyTransform("https://img.pixelvault.dev/proj/img.png", "?tile=logo.png"), + ).toBe("https://img.pixelvault.dev/proj/img.png?tile=logo.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", + ); + }); +}); From 9c22cdaecd0c2a46d6b8118a82ffa80717d4a606 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Tue, 7 Jul 2026 17:32:31 +0200 Subject: [PATCH 2/2] fix(get): harden per review council (encoding, download safety, --json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Council (Claude + Codex) findings fixed: - applyTransform now trims + reserializes params via URLSearchParams, so reserved chars in values are percent-encoded — `-t "background=#ffaa00"` previously sent `background=` (the `#` was parsed as a URL fragment). Also fixes whitespace-only `-t " "`. - encodeURIComponent(args.id) — a raw id like `../whoami` no longer manipulates the request path. - --json is now honored in download mode (was silently overridden by -o) — emits metadata + transformed_url + output. - Refuse to write a 0-byte download; wrap writeFileSync failures in CliError (friendly message instead of a raw fs error). - Docs: standardize the tile example on img_logo.png; note that -t encodes for you. +2 tests (hex encoding, folder-tile encoding); 18 total. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++- src/commands/get.ts | 43 +++++++++++++++++++++++++++++++------- tests/commands/get.test.ts | 33 ++++++++++++++++++----------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index cfb3c8e..ea85a6d 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ pixelvault get img_xyz -t "w=400&fmt=webp" # just print the transformed 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=`). +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 diff --git a/src/commands/get.ts b/src/commands/get.ts index 9276a66..0d5625b 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -21,12 +21,18 @@ interface GetResponse { * 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 q = transform.replace(/^[?&]+/, ""); - if (!q) return url; - return url.includes("?") ? `${url}&${q}` : `${url}?${q}`; + 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({ @@ -49,7 +55,7 @@ export default defineCommand({ transform: { type: "string", description: - 'Transform params to apply, e.g. "w=400&fmt=webp", "segment=foreground", "tile=logo.png"', + 'Transform params to apply, e.g. "w=400&fmt=webp", "segment=foreground", "tile=img_logo.png"', alias: "t", }, json: { @@ -62,7 +68,7 @@ export default defineCommand({ const apiKey = requireApiKey(); const res = await apiRequest({ - path: `/v1/images/${args.id}`, + path: `/v1/images/${encodeURIComponent(args.id)}`, auth: apiKey, }); @@ -70,6 +76,7 @@ export default defineCommand({ // 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); @@ -84,9 +91,29 @@ export default defineCommand({ 1, ); } - writeFileSync(args.output as string, Buffer.from(bytes)); - stderr(`Saved ${args.output}`); - stdout(url); // URL to stdout (machine-parseable), confirmation to stderr + // 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; } diff --git a/tests/commands/get.test.ts b/tests/commands/get.test.ts index 4fc3c8f..88524ce 100644 --- a/tests/commands/get.test.ts +++ b/tests/commands/get.test.ts @@ -14,28 +14,37 @@ describe("get command", () => { }); describe("applyTransform", () => { + const U = "https://img.pixelvault.dev/proj/img.png"; + it("returns the URL unchanged when no transform is given", () => { - const u = "https://img.pixelvault.dev/proj/img.png"; - expect(applyTransform(u)).toBe(u); - expect(applyTransform(u, "")).toBe(u); + 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("https://img.pixelvault.dev/proj/img.png", "w=400&fmt=webp"), - ).toBe("https://img.pixelvault.dev/proj/img.png?w=400&fmt=webp"); + 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("https://img.pixelvault.dev/proj/img.png?v=2", "segment=foreground"), - ).toBe("https://img.pixelvault.dev/proj/img.png?v=2&segment=foreground"); + expect(applyTransform(`${U}?v=2`, "segment=foreground")).toBe( + `${U}?v=2&segment=foreground`, + ); }); it("tolerates a leading ? or & in the transform string", () => { - expect( - applyTransform("https://img.pixelvault.dev/proj/img.png", "?tile=logo.png"), - ).toBe("https://img.pixelvault.dev/proj/img.png?tile=logo.png"); + 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`); }); });