From b63606a9c35847902857148a99840f0e1c378f1b Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 29 Oct 2026 09:51:04 +0100 Subject: [PATCH] feat(upload): --idempotency-key and --dedupe --idempotency-key sends the Idempotency-Key header (retry-safe; single file only). --dedupe asks the API to reuse an identical existing image. Both validated up front; combination rejected. Replay/dedup notes go to stderr so stdout stays the bare URL. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + src/commands/upload.ts | 39 ++++++++++++++++++++++++++++++++ src/lib/upload-options.ts | 28 +++++++++++++++++++++++ tests/lib/upload-options.test.ts | 25 ++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/lib/upload-options.ts create mode 100644 tests/lib/upload-options.test.ts diff --git a/README.md b/README.md index b15691b..3786af4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ pixelvault export -o my-images.tar The CLI is designed for AI coding agents. Output contract: - `upload` prints **only the URL** to stdout (add `--optimized` for the smaller WebP/AVIF variant; it falls back to the original URL for SVG, GIF and private uploads) +- `upload --idempotency-key ` makes retries safe: repeating the same key with the same file returns the original image instead of storing a copy (a note goes to stderr; single file only). `upload --dedupe` reuses an identical image already in your project without counting another upload. The two can't be combined - `list` outputs one URL per line - `delete` outputs nothing on success - All human messages go to **stderr** diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 0985321..7fca693 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -4,6 +4,7 @@ import { basename } from "node:path"; import { apiRequest } from "../lib/client.js"; import { requireApiKey } from "../lib/config.js"; import { parseExpires } from "../lib/duration.js"; +import { validateReuseOptions } from "../lib/upload-options.js"; import { stdout, stderr, jsonOut } from "../lib/output.js"; interface UploadResponse { @@ -18,6 +19,10 @@ interface UploadResponse { folder: string | null; created_at: string; signed_expires?: number; + /** The API reused an identical existing image (--dedupe). */ + deduplicated?: boolean; + /** The API returned the original of a repeated --idempotency-key. */ + replayed?: boolean; }; } @@ -53,6 +58,17 @@ export default defineCommand({ "Print the optimized URL (WebP/AVIF, usually much smaller) instead of the original; falls back to the original URL when none is returned (SVG, GIF, private)", default: false, }, + "idempotency-key": { + type: "string", + description: + "Make retries safe: repeating the same key with the same file returns the original image instead of storing a copy (single file only)", + }, + dedupe: { + type: "boolean", + description: + "Reuse an identical image already in your project instead of storing and counting another upload", + default: false, + }, json: { type: "boolean", description: "Output full JSON response", @@ -82,6 +98,17 @@ export default defineCommand({ const files = args._.length ? args._.map(String) : [String(args.files)]; let hadError = false; + const idempotencyKey = args["idempotency-key"] as string | undefined; + const reuseError = validateReuseOptions({ + idempotencyKey, + dedupe: args.dedupe, + fileCount: files.length, + }); + if (reuseError) { + stderr(reuseError); + process.exit(1); + } + const results: UploadResponse["data"][] = []; for (const filePath of files) { @@ -109,13 +136,25 @@ export default defineCommand({ } } + if (args.dedupe) { + formData.append("dedupe", "true"); + } + const res = await apiRequest({ method: "POST", path: "/v1/images", auth: apiKey, formData, + ...(idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : {}), }); + // stderr only: stdout stays the bare URL so scripts keep working. + if (res.data.replayed) { + stderr(`Replayed: ${filePath} was already uploaded with this key; returning the original.`); + } else if (res.data.deduplicated) { + stderr(`Deduplicated: ${filePath} matches an existing image; nothing new was stored.`); + } + // Fail closed: if we asked for private but the server didn't confirm it, // don't hand back a URL the user would wrongly trust as private. if (args.private && res.data.visibility !== "private") { diff --git a/src/lib/upload-options.ts b/src/lib/upload-options.ts new file mode 100644 index 0000000..4c16212 --- /dev/null +++ b/src/lib/upload-options.ts @@ -0,0 +1,28 @@ +/** + * Validation for the retry-safety / reuse flags on `upload`, done up front so a + * bad combination fails before any file is read or sent. + */ + +// Mirrors the API: 1-255 printable ASCII characters, no spaces. +const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,255}$/; + +/** Returns an error message for an invalid flag combination, or null when fine. */ +export function validateReuseOptions(opts: { + idempotencyKey?: string; + dedupe?: boolean; + fileCount: number; +}): string | null { + const { idempotencyKey, dedupe, fileCount } = opts; + if (idempotencyKey !== undefined) { + if (!IDEMPOTENCY_KEY.test(idempotencyKey)) { + return "--idempotency-key must be 1-255 printable ASCII characters with no spaces."; + } + if (dedupe) { + return "--dedupe cannot be combined with --idempotency-key. Use the key for retry-safety, or --dedupe to reuse an identical image."; + } + if (fileCount > 1) { + return "--idempotency-key identifies a single upload, so it can't be used with multiple files. Upload them one at a time with distinct keys."; + } + } + return null; +} diff --git a/tests/lib/upload-options.test.ts b/tests/lib/upload-options.test.ts new file mode 100644 index 0000000..805d8ad --- /dev/null +++ b/tests/lib/upload-options.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { validateReuseOptions } from "../../src/lib/upload-options.js"; + +describe("validateReuseOptions", () => { + it("accepts no flags, a lone dedupe, and a key on a single file", () => { + expect(validateReuseOptions({ fileCount: 3 })).toBeNull(); + expect(validateReuseOptions({ dedupe: true, fileCount: 3 })).toBeNull(); + expect(validateReuseOptions({ idempotencyKey: "run-42:shot_1", fileCount: 1 })).toBeNull(); + }); + + it("rejects keys the API would reject", () => { + for (const bad of ["", "has space", "a".repeat(256), "café"]) { + expect(validateReuseOptions({ idempotencyKey: bad, fileCount: 1 })).toMatch(/printable ASCII/); + } + expect(validateReuseOptions({ idempotencyKey: "a".repeat(255), fileCount: 1 })).toBeNull(); + }); + + it("rejects dedupe combined with a key", () => { + expect(validateReuseOptions({ idempotencyKey: "k", dedupe: true, fileCount: 1 })).toMatch(/cannot be combined/); + }); + + it("rejects one key for several files (they'd collide as different bytes)", () => { + expect(validateReuseOptions({ idempotencyKey: "k", fileCount: 2 })).toMatch(/multiple files/); + }); +});