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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pixelvault-cli",
"version": "0.3.0",
"version": "0.4.0",
"description": "CLI for PixelVault — agent-first image hosting",
"type": "module",
"bin": {
Expand Down
47 changes: 47 additions & 0 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { apiRequest } from "../lib/client.js";
import { requireApiKey } from "../lib/config.js";
import { parseExpires } from "../lib/duration.js";
import { stdout, stderr, jsonOut } from "../lib/output.js";

interface UploadResponse {
data: {
id: string;
url: string;
visibility?: "public" | "private";
mime_type: string;
size: number;
filename: string;
folder: string | null;
created_at: string;
signed_expires?: number;
};
}

Expand All @@ -32,6 +35,17 @@ export default defineCommand({
type: "string",
description: "Folder to organize images in",
},
private: {
type: "boolean",
description:
"Upload privately — returns a signed URL only holders of the link can open (needs a secret key)",
default: false,
},
expires: {
type: "string",
description:
"Lifetime of a --private signed link: e.g. 30m, 12h, 7d (default 7d; min 60s, max 30d)",
},
json: {
type: "boolean",
description: "Output full JSON response",
Expand All @@ -41,6 +55,21 @@ export default defineCommand({
async run({ args }) {
const apiKey = requireApiKey();

// Resolve the signed-link lifetime up front so a bad value fails before any
// upload happens. `--expires` only makes sense for a private (signed) URL.
let signExpires: number | null = null;
if (args.expires !== undefined) {
if (!args.private) {
stderr("--expires only applies to --private uploads (it sets the signed link's lifetime).");
process.exit(1);
}
signExpires = parseExpires(String(args.expires));
}

if (args.private && args.folder) {
stderr("Note: --folder is ignored for --private uploads.");
}

// citty passes positional as a single string; we handle globs via shell expansion
const files = Array.isArray(args.files) ? args.files : [args.files];
let hadError = false;
Expand All @@ -62,13 +91,31 @@ export default defineCommand({
formData.append("folder", args.folder);
}

if (args.private) {
// The server routes private images to a signed-URL-only path and
// returns the ready-to-share signed URL in `data.url`. A user-supplied
// folder is ignored for private uploads (server-side).
formData.append("visibility", "private");
if (signExpires !== null) {
formData.append("sign_expires_in", String(signExpires));
}
}

const res = await apiRequest<UploadResponse>({
method: "POST",
path: "/v1/images",
auth: apiKey,
formData,
});

// 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") {
hadError = true;
stderr(`Refusing to report ${filePath}: server did not confirm a private upload.`);
continue;
}

if (args.json) {
results.push(res.data);
} else {
Expand Down
31 changes: 31 additions & 0 deletions src/lib/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CliError } from "./errors.js";

/** Signed-URL lifetime bounds, matching the API's `sign_expires_in` (60s..30d). */
export const MIN_EXPIRES_SECONDS = 60;
export const MAX_EXPIRES_SECONDS = 30 * 24 * 60 * 60;

/**
* Parse a signed-link lifetime like `30m`, `12h`, `7d`, `90s`, or a bare number
* of seconds, into seconds. Rejects anything outside the API's accepted range so
* a bad value fails locally with a clear message instead of a 400 from the server.
*/
export function parseExpires(input: string): number {
const match = /^(\d+)\s*([smhd]?)$/i.exec(input.trim());
if (!match) {
throw new CliError(
`Invalid --expires "${input}". Use e.g. 30m, 12h, 7d, or a number of seconds.`
);
}

const unit = (match[2] ?? "").toLowerCase();
const multiplier = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
const seconds = Number(match[1] ?? "") * multiplier;

if (seconds < MIN_EXPIRES_SECONDS || seconds > MAX_EXPIRES_SECONDS) {
throw new CliError(
`--expires must be between 60s and 30d (got ${seconds}s).`
);
}

return seconds;
}
35 changes: 35 additions & 0 deletions tests/lib/duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { parseExpires, MIN_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS } from "../../src/lib/duration.js";
import { CliError } from "../../src/lib/errors.js";

describe("parseExpires", () => {
it("parses unit suffixes into seconds", () => {
expect(parseExpires("90s")).toBe(90);
expect(parseExpires("30m")).toBe(1800);
expect(parseExpires("12h")).toBe(43200);
expect(parseExpires("7d")).toBe(604800);
});

it("treats a bare number as seconds and tolerates whitespace/case", () => {
expect(parseExpires("3600")).toBe(3600);
expect(parseExpires(" 7D ")).toBe(604800);
});

it("accepts the inclusive bounds exactly (60s .. 30d)", () => {
expect(parseExpires("60")).toBe(MIN_EXPIRES_SECONDS);
expect(parseExpires("30d")).toBe(MAX_EXPIRES_SECONDS);
});

it("rejects values outside the accepted range", () => {
expect(() => parseExpires("59")).toThrow(CliError); // below min
expect(() => parseExpires("0")).toThrow(CliError);
expect(() => parseExpires("31d")).toThrow(CliError); // above max
expect(() => parseExpires("2592001")).toThrow(CliError);
});

it("rejects malformed input", () => {
for (const bad of ["", "abc", "7w", "-1", "7.5d", "d7", "7 days"]) {
expect(() => parseExpires(bad)).toThrow(CliError);
}
});
});
Loading