Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,6 +54,9 @@ program
return lines.join("\n");
});

registerLoginCommand(program);
registerLogoutCommand(program);
registerWhoamiCommand(program);
registerInitCommand(program);
registerSetupCommand(program);
registerStatusCommand(program);
Expand Down
131 changes: 131 additions & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise((r) => setTimeout(r, ms));
}
26 changes: 26 additions & 0 deletions src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
});
}
45 changes: 45 additions & 0 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -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")}`,
]);
});
}
30 changes: 30 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -68,6 +82,22 @@ export class AffitorAPI {
);
}

// ─── Auth endpoints ───────────────────────────────────────────

async authStart(): Promise<AuthStartResponse> {
return this.request<AuthStartResponse>("/api/v1/cli/auth/start", {
method: "POST",
});
}

async authPoll(state: string): Promise<AuthPollResponse> {
return this.request<AuthPollResponse>(
`/api/v1/cli/auth/poll?state=${encodeURIComponent(state)}`,
);
}

// ─── Program endpoints ──────────────────────────────────────────

async sendTestEvent(data: {
program_id: string;
event_type: "click" | "lead" | "sale";
Expand Down
19 changes: 10 additions & 9 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}
Expand Down