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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ npx pixelvault-cli upload photo.jpg
## Quick Start

```bash
# Create an account
# Create an account (prompts for email + optional password)
pixelvault register

# Non-interactive / agent signup — no password, key saved automatically
pixelvault register --email you@example.com --passwordless

# Upload an image — prints the URL to stdout
pixelvault upload screenshot.png
# https://img.pixelvault.dev/proj_abc/img_xyz.png
Expand Down
54 changes: 46 additions & 8 deletions src/commands/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface RegisterResponse {
account_id: string;
email: string;
email_verified: boolean;
// Optional: older API versions omit it. Absent → treat as "unknown".
password_set?: boolean;
plan: string;
default_project: {
id: string;
Expand All @@ -30,19 +32,43 @@ export default defineCommand({
},
password: {
type: "string",
description: "Password (min 8 characters)",
description: "Password (min 8 characters). Omit for a passwordless account.",
},
passwordless: {
type: "boolean",
description:
"Create the account without a password (for agents/automation). Set a password later via the web 'Forgot password' flow.",
},
},
async run({ args }) {
const email = args.email || (await prompt("Email: "));
const password = args.password || (await promptPassword("Password: "));
const interactive = Boolean(process.stdin.isTTY);

if (!email || !password) {
stderr("Email and password are required.");
// Email is always required. Only prompt when we have a TTY — in a headless
// (agent) run, missing --email is a hard error rather than a hung prompt.
const email = args.email || (interactive ? await prompt("Email: ") : "");
if (!email) {
stderr("Email is required. Pass --email <email>.");
process.exit(1);
}

if (password.length < 8) {
// Resolve the password. Precedence: explicit --password > --passwordless >
// interactive prompt (blank = passwordless) > headless default (passwordless,
// so a non-TTY run never blocks on a hidden prompt).
let password: string | undefined;
if (args.password) {
password = args.password;
} else if (args.passwordless) {
password = undefined;
} else if (interactive) {
const entered = await promptPassword(
"Password (leave blank for a passwordless account): "
);
password = entered || undefined;
} else {
password = undefined;
}

if (password !== undefined && password.length < 8) {
stderr("Password must be at least 8 characters.");
process.exit(1);
}
Expand All @@ -52,7 +78,9 @@ export default defineCommand({
const res = await apiRequest<RegisterResponse>({
method: "POST",
path: "/v1/auth/register",
body: { email, password },
// Only send the password field when one was chosen — omitting it creates
// a passwordless account server-side.
body: password ? { email, password } : { email },
});

updateConfig({
Expand All @@ -65,8 +93,18 @@ export default defineCommand({
stderr(`Email: ${res.data.email}`);
stderr(`Project: ${res.data.default_project.id}`);

// password_set === false (or a passwordless request on an older API) means
// there's no dashboard login yet — tell the user how to claim one.
const passwordless = res.data.password_set === false || !password;
if (passwordless) {
stderr(
"No password set. To enable dashboard login, use 'Forgot password' at " +
"https://pixelvault.dev/forgot-password to set one."
);
}

if (!res.data.email_verified) {
stderr("Check your email to verify your account before uploading.");
stderr("Verify your email to lift the upload limit for the free tier.");
}
},
});
80 changes: 80 additions & 0 deletions tests/commands/register.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Capture the outbound request so we can assert whether `password` is sent.
const apiRequest = vi.fn();
const updateConfig = vi.fn();

vi.mock("../../src/lib/client.js", () => ({
apiRequest: (...args: unknown[]) => apiRequest(...args),
}));
vi.mock("../../src/lib/config.js", () => ({
updateConfig: (...args: unknown[]) => updateConfig(...args),
}));
vi.mock("../../src/lib/output.js", () => ({ stderr: () => {} }));
vi.mock("../../src/lib/prompt.js", () => ({
prompt: vi.fn(),
promptPassword: vi.fn(),
}));

import register from "../../src/commands/register.js";

function canned(overrides: Record<string, unknown> = {}) {
return {
data: {
account_id: "acct_1",
email: "a@b.com",
email_verified: false,
password_set: false,
plan: "free",
default_project: {
id: "proj_1",
name: "Default",
api_keys: { live: "pv_live_x" },
},
...overrides,
},
};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const run = (args: Record<string, unknown>) =>
(register as any).run({ args, rawArgs: [], cmd: register });

function sentBody() {
return apiRequest.mock.calls[0][0].body;
}

describe("register command — password handling", () => {
beforeEach(() => {
apiRequest.mockReset().mockResolvedValue(canned());
updateConfig.mockReset();
// Simulate a headless (agent) run.
Object.defineProperty(process.stdin, "isTTY", {
value: false,
configurable: true,
});
});

it("omits password with --passwordless", async () => {
await run({ email: "a@b.com", passwordless: true });
expect(sentBody()).toEqual({ email: "a@b.com" });
});

it("includes password when provided", async () => {
apiRequest.mockResolvedValue(canned({ password_set: true }));
await run({ email: "a@b.com", password: "longenough1" });
expect(sentBody()).toEqual({ email: "a@b.com", password: "longenough1" });
});

it("defaults to passwordless in a non-TTY run with no password flags", async () => {
await run({ email: "a@b.com" });
expect(sentBody()).toEqual({ email: "a@b.com" });
});

it("saves the returned API key to config", async () => {
await run({ email: "a@b.com", passwordless: true });
expect(updateConfig).toHaveBeenCalledWith(
expect.objectContaining({ api_key: "pv_live_x", default_project: "proj_1" })
);
});
});
Loading