From b63bc044d11eade8d77ed3fe9842168e8aabbce1 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sat, 11 Apr 2026 00:51:09 -0700 Subject: [PATCH 1/2] feat: add login, logout, whoami commands for browser-based auth Opens dashboard login page, polls CMS for JWT via polling handshake. Credentials stored in ~/.affitor/credentials.json (mode 0600). Supports --json flag for agent automation. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 6 ++ src/commands/login.ts | 131 +++++++++++++++++++++++++++++++++++++++++ src/commands/logout.ts | 26 ++++++++ src/commands/whoami.ts | 45 ++++++++++++++ src/lib/api-client.ts | 30 ++++++++++ 5 files changed, 238 insertions(+) create mode 100644 src/commands/login.ts create mode 100644 src/commands/logout.ts create mode 100644 src/commands/whoami.ts diff --git a/src/cli.ts b/src/cli.ts index 7f9a886..ce808de 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,9 @@ import { Command } from "commander"; import pc from "picocolors"; import { setLoggerOptions } from "./lib/logger.js"; +import { registerLoginCommand } from "./commands/login.js"; +import { registerLogoutCommand } from "./commands/logout.js"; +import { registerWhoamiCommand } from "./commands/whoami.js"; import { registerInitCommand } from "./commands/init.js"; import { registerSetupCommand } from "./commands/setup-stripe.js"; import { registerStatusCommand } from "./commands/status.js"; @@ -51,6 +54,9 @@ program return lines.join("\n"); }); +registerLoginCommand(program); +registerLogoutCommand(program); +registerWhoamiCommand(program); registerInitCommand(program); registerSetupCommand(program); registerStatusCommand(program); diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..6c348fd --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,131 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import { exec } from "node:child_process"; +import { AffitorAPI } from "../lib/api-client.js"; +import { writeCredentials, readCredentials } from "../lib/config.js"; +import * as logger from "../lib/logger.js"; +import type { UserCredentials } from "../types.js"; +import { DEFAULT_API_URL } from "../types.js"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export function registerLoginCommand(parent: Command) { + parent + .command("login") + .description("Log in to Affitor via your browser") + .action(async (_opts, cmd) => { + const flags = cmd.optsWithGlobals(); + await runLogin(flags); + }); +} + +async function runLogin(flags: { apiUrl?: string; json?: boolean }) { + // Check if already logged in + const existing = readCredentials(); + if (existing) { + if (flags.json) { + logger.json({ already_logged_in: true, email: existing.email }); + return; + } + logger.info(""); + logger.info(` Already logged in as ${pc.cyan(existing.email)}`); + logger.info(` Run ${pc.dim("affitor logout")} to switch accounts.`); + logger.info(""); + return; + } + + const api = new AffitorAPI({ apiUrl: flags.apiUrl ?? DEFAULT_API_URL }); + + // Step 1: Start auth session + logger.info(""); + logger.step("Starting login..."); + + const { state, auth_url, expires_at } = await api.authStart(); + + // Step 2: Open browser + logger.info(""); + logger.info(` ${pc.bold("Open this URL to log in:")}`); + logger.info(""); + logger.info(` ${pc.cyan(auth_url)}`); + logger.info(""); + + openBrowser(auth_url); + logger.step("Waiting for browser login..."); + + // Step 3: Poll until complete or expired + const deadline = new Date(expires_at).getTime(); + const startTime = Date.now(); + let dots = 0; + + while (Date.now() < deadline && Date.now() - startTime < POLL_TIMEOUT_MS) { + await sleep(POLL_INTERVAL_MS); + + const result = await api.authPoll(state); + + if (result.status === "complete" && result.token) { + const creds: UserCredentials = { + token: result.token, + email: result.email ?? "", + user_id: "", + advertiser_id: String(result.advertiser_id ?? ""), + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + created_at: new Date().toISOString(), + }; + + writeCredentials(creds); + + if (flags.json) { + logger.json({ logged_in: true, email: creds.email }); + return; + } + + logger.info(""); + logger.success(`Logged in as ${pc.cyan(creds.email)}`); + logger.info(""); + logger.info(` Credentials saved to ${pc.dim("~/.affitor/credentials.json")}`); + logger.info(` Token expires in 90 days.`); + logger.info(""); + return; + } + + if (result.status === "expired") { + logger.error("Login session expired. Run `affitor login` to try again."); + process.exit(1); + } + + if (result.status === "consumed") { + logger.error("Login session already used. Run `affitor login` to try again."); + process.exit(1); + } + + // Show waiting indicator + dots = (dots + 1) % 4; + if (!flags.json) { + process.stdout.write(`\r ${pc.dim("Waiting" + ".".repeat(dots) + " ".repeat(3 - dots))} `); + } + } + + logger.info(""); + logger.error("Login timed out. Run `affitor login` to try again."); + process.exit(1); +} + +function openBrowser(url: string) { + const cmd = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"`; + + exec(cmd, (err) => { + if (err) { + logger.debug(`Failed to open browser: ${err.message}`); + } + }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 0000000..7e0c3e6 --- /dev/null +++ b/src/commands/logout.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; +import { deleteCredentials, readCredentials } from "../lib/config.js"; +import * as logger from "../lib/logger.js"; + +export function registerLogoutCommand(parent: Command) { + parent + .command("logout") + .description("Log out and remove stored credentials") + .action(async (_opts, cmd) => { + const flags = cmd.optsWithGlobals(); + + const existing = readCredentials(); + deleteCredentials(); + + if (flags.json) { + logger.json({ logged_out: true, email: existing?.email ?? null }); + return; + } + + if (existing) { + logger.success(`Logged out (was ${existing.email})`); + } else { + logger.info(" Not currently logged in."); + } + }); +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..eb847e4 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import { readCredentials } from "../lib/config.js"; +import * as logger from "../lib/logger.js"; + +export function registerWhoamiCommand(parent: Command) { + parent + .command("whoami") + .description("Show the currently logged-in user") + .action(async (_opts, cmd) => { + const flags = cmd.optsWithGlobals(); + const creds = readCredentials(); + + if (!creds) { + if (flags.json) { + logger.json({ logged_in: false }); + return; + } + logger.info(""); + logger.info(` Not logged in. Run ${pc.cyan("affitor login")} to authenticate.`); + logger.info(""); + process.exit(1); + } + + if (flags.json) { + logger.json({ + logged_in: true, + email: creds.email, + advertiser_id: creds.advertiser_id || null, + expires_at: creds.expires_at, + }); + return; + } + + const expiresIn = Math.ceil( + (new Date(creds.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); + + logger.titledBox("Account", [ + `Email ${pc.cyan(creds.email)}`, + `Advertiser ${creds.advertiser_id || pc.dim("none")}`, + `Token expires ${expiresIn > 0 ? `in ${expiresIn} days` : pc.red("expired")}`, + ]); + }); +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index d6a15ab..dbb8a0d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -8,6 +8,20 @@ import type { import * as logger from "./logger.js"; import { resolveApiKey } from "./config.js"; +export interface AuthStartResponse { + state: string; + auth_url: string; + poll_url: string; + expires_at: string; +} + +export interface AuthPollResponse { + status: "pending" | "complete" | "expired" | "consumed"; + token?: string; + email?: string; + advertiser_id?: number; +} + interface RequestOptions { method?: string; body?: Record; @@ -68,6 +82,22 @@ export class AffitorAPI { ); } + // ─── Auth endpoints ─────────────────────────────────────────── + + async authStart(): Promise { + return this.request("/api/v1/cli/auth/start", { + method: "POST", + }); + } + + async authPoll(state: string): Promise { + return this.request( + `/api/v1/cli/auth/poll?state=${encodeURIComponent(state)}`, + ); + } + + // ─── Program endpoints ────────────────────────────────────────── + async sendTestEvent(data: { program_id: string; event_type: "click" | "lead" | "sale"; From 13bb184e1da84943aa525578d80930022dfffe03 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sat, 11 Apr 2026 01:29:36 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20deleteCredentials=20crash=20?= =?UTF-8?q?=E2=80=94=20use=20unlinkSync=20directly,=20handle=20empty=20JSO?= =?UTF-8?q?N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/lib/config.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index 080544d..686dcf7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; import { @@ -125,15 +125,18 @@ export function readCredentials(): UserCredentials | null { const path = getCredentialsPath(); if (!existsSync(path)) return null; - const raw = readFileSync(path, "utf-8"); - const creds = JSON.parse(raw) as UserCredentials; + const raw = readFileSync(path, "utf-8").trim(); + if (!raw) return null; - // Check expiry - if (new Date(creds.expires_at) < new Date()) { + try { + const creds = JSON.parse(raw) as UserCredentials; + if (new Date(creds.expires_at) < new Date()) { + return null; + } + return creds; + } catch { return null; } - - return creds; } export function writeCredentials(creds: UserCredentials): void { @@ -150,8 +153,6 @@ export function writeCredentials(creds: UserCredentials): void { export function deleteCredentials(): void { const path = getCredentialsPath(); if (existsSync(path)) { - writeFileSync(path, ""); - const { unlinkSync } = require("node:fs"); unlinkSync(path); } }