From 2c351efa6d0b5894753d4b73d97c9666667fcf22 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 9 Jul 2026 21:56:17 +0200 Subject: [PATCH] feat: passwordless register (--passwordless, non-TTY default) register's password is now optional, matching the API's passwordless (agent) signup. Precedence: --password > --passwordless > interactive prompt (blank = passwordless) > headless default (passwordless). A non-TTY run never blocks on a hidden password prompt, and missing --email in a headless run is a clear error instead of a hang. The request omits the password field entirely for passwordless signup, handles the new password_set response field, and prints guidance to claim dashboard login via the web Forgot-password flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 5 ++- src/commands/register.ts | 54 ++++++++++++++++++---- tests/commands/register.test.ts | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 tests/commands/register.test.ts diff --git a/README.md b/README.md index ea85a6d..e84e851 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/commands/register.ts b/src/commands/register.ts index 45e7818..bac4878 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -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; @@ -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 ."); 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); } @@ -52,7 +78,9 @@ export default defineCommand({ const res = await apiRequest({ 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({ @@ -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."); } }, }); diff --git a/tests/commands/register.test.ts b/tests/commands/register.test.ts new file mode 100644 index 0000000..e045ede --- /dev/null +++ b/tests/commands/register.test.ts @@ -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 = {}) { + 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) => + (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" }) + ); + }); +});