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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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**
Expand Down
39 changes: 39 additions & 0 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
};
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -109,13 +136,25 @@ export default defineCommand({
}
}

if (args.dedupe) {
formData.append("dedupe", "true");
}

const res = await apiRequest<UploadResponse>({
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") {
Expand Down
28 changes: 28 additions & 0 deletions src/lib/upload-options.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions tests/lib/upload-options.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading