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
29 changes: 21 additions & 8 deletions packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import * as logger from "../lib/logger.js";
import { format } from "../lib/logger.js";
import { readConfig, ConfigNotFoundError, apiKeySourceLabel } from "../lib/config.js";
import { readConfigOrKeyOnly, ConfigNotFoundError, apiKeySourceLabel } from "../lib/config.js";
import { AffitorAPI, APIError, NetworkError } from "../lib/api-client.js";
import { getFlags } from "../lib/flags.js";
import type { CLIFlags } from "../types.js";
Expand All @@ -18,7 +18,7 @@ export function registerStatusCommand(program: Command) {
export async function runStatus(flags: CLIFlags) {
let config;
try {
config = readConfig();
config = readConfigOrKeyOnly(flags);
} catch (err) {
if (err instanceof ConfigNotFoundError) {
logger.error(err.message);
Expand All @@ -29,11 +29,11 @@ export async function runStatus(flags: CLIFlags) {
}

const api = AffitorAPI.fromFlags(
{ apiKey: flags.apiKey, apiUrl: flags.apiUrl ?? config.api_url },
{ apiKey: flags.apiKey, apiUrl: flags.apiUrl ?? config?.api_url },
);

try {
const status = await api.getStatus(config.program_id);
const status = await api.getStatus(config?.program_id);

if (flags.json) {
logger.json(status);
Expand All @@ -56,14 +56,18 @@ export async function runStatus(flags: CLIFlags) {
: "warn"
: "error";

// The config's domain when there is one; otherwise the website the API returns, which keeps
// its path ("example.com/pricing/"), reduced to its hostname for the DNS hint.
const domain = config?.domain ?? hostnameOf(status.domain);

// Find max for progress bars
const events = status.recent_events;
const maxEvent = Math.max(events.clicks_24h, events.leads_24h, events.sales_24h, 1);

logger.banner();

logger.titledBox(status.name, [
` ${format.dim(config.domain)}`,
` ${format.dim(domain)}`,
` ${format.dim("ID:")} ${status.program_id}`,
"",
` ${format.bold("Integrations")}`,
Expand All @@ -79,8 +83,8 @@ export async function runStatus(flags: CLIFlags) {
]);

// Action hints
if (!status.dns_verified) {
logger.step(`DNS: Add CNAME t.${config.domain} → track.affitor.com`);
if (!status.dns_verified && domain) {
logger.step(`DNS: Add CNAME t.${domain} → track.affitor.com`);
}
if (!status.stripe_connected) {
logger.step("Stripe: Connect it in the Affitor dashboard (program Settings, Connect Stripe)");
Expand All @@ -98,7 +102,7 @@ export async function runStatus(flags: CLIFlags) {
// only AFFITOR_API_KEY would leave the refused key on the wire.
logger.error(
"API key expired or invalid.\n" +
` Ask a workspace owner of program ${config.program_id} to open Affitor → Settings → API Key and click Regenerate.\n` +
` Ask a workspace owner of ${config ? `program ${config.program_id}` : "the program"} to open Affitor → Settings → API Key and click Regenerate.\n` +
` Then replace the key in ${apiKeySourceLabel(flags)} with the new one.`,
);
} else {
Expand All @@ -115,3 +119,12 @@ export async function runStatus(flags: CLIFlags) {
process.exit(1);
}
}

function hostnameOf(site: string | undefined): string {
if (!site) return "";
try {
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(site) ? site : `https://${site}`).hostname;
} catch {
return "";
}
}
10 changes: 5 additions & 5 deletions packages/cli/src/commands/test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import * as logger from "../lib/logger.js";
import { format } from "../lib/logger.js";
import { readConfig, ConfigNotFoundError } from "../lib/config.js";
import { readConfigOrKeyOnly, ConfigNotFoundError } from "../lib/config.js";
import { AffitorAPI, APIError, NetworkError } from "../lib/api-client.js";
import { getFlags } from "../lib/flags.js";
import type { CLIFlags } from "../types.js";
Expand All @@ -15,10 +15,10 @@ export function registerTestCommand(program: Command) {
});
}

async function runTest(eventType: string | undefined, flags: CLIFlags) {
export async function runTest(eventType: string | undefined, flags: CLIFlags) {
let config;
try {
config = readConfig();
config = readConfigOrKeyOnly(flags);
} catch (err) {
if (err instanceof ConfigNotFoundError) {
logger.error(err.message);
Expand All @@ -44,14 +44,14 @@ async function runTest(eventType: string | undefined, flags: CLIFlags) {
}

const api = AffitorAPI.fromFlags(
{ apiKey: flags.apiKey, apiUrl: flags.apiUrl ?? config.api_url },
{ apiKey: flags.apiKey, apiUrl: flags.apiUrl ?? config?.api_url },
);

logger.newline();

try {
const result = await api.sendTestEvent({
program_id: config.program_id,
program_id: config?.program_id,
event_type: type,
});

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ export class AffitorAPI {
});
}

async getStatus(programId: string): Promise<ProgramStatus> {
async getStatus(programId?: string): Promise<ProgramStatus> {
return this.request<ProgramStatus>(
`/api/v1/cli/status?program_id=${programId}`,
programId ? `/api/v1/cli/status?program_id=${programId}` : "/api/v1/cli/status",
);
}

Expand All @@ -141,7 +141,7 @@ export class AffitorAPI {
}

async sendTestEvent(data: {
program_id: string;
program_id?: string;
event_type: "click" | "lead" | "sale";
}): Promise<TestEventResult> {
return this.request<TestEventResult>("/api/v1/cli/test-event", {
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ export function readConfig(cwd?: string): AffitorConfig {
return config;
}

/**
* The project config, or null when this directory has none but a key was passed with
* `--api-key` or AFFITOR_API_KEY. The API finds the program from the key alone, so `status` and
* `test` work for a program created in the dashboard, without `init` (which makes a new one).
*/
export function readConfigOrKeyOnly(flags: { apiKey?: string }, cwd?: string): AffitorConfig | null {
try {
return readConfig(cwd);
} catch (err) {
if (err instanceof ConfigNotFoundError && (flags.apiKey || process.env.AFFITOR_API_KEY)) return null;
throw err;
}
}

export function writeConfig(config: AffitorConfig, cwd?: string): void {
const dir = getConfigDir(cwd);
if (!existsSync(dir)) {
Expand Down Expand Up @@ -463,7 +477,7 @@ export class ConfigNotFoundError extends Error {
constructor(dir: string) {
super(
`No Affitor config found in ${dir}.\n` +
`Run ${"`npx affitor init`"} to set up your program.`,
`Run ${"`npx affitor init`"} to set up a new program, or set AFFITOR_API_KEY to use an existing one.`,
);
this.name = "ConfigNotFoundError";
}
Expand Down
136 changes: 136 additions & 0 deletions packages/cli/tests/status-test-key-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runStatus } from "../src/commands/status";
import { runTest } from "../src/commands/test";
import type { CLIFlags } from "../src/types";

/**
* QA-33: a brand whose program was created in the dashboard has its API key but no
* `.affitor/config.json` (only `init` writes one, and `init` creates a NEW program). The API
* finds the program from the key alone, so `status` and `test` run with `--api-key` or
* AFFITOR_API_KEY and no config, and show the program's domain from the API.
*/

const API_URL = "http://key-only.test.local";
const flags: CLIFlags = { json: false, noInteractive: true, autoConfirm: true, quiet: false, apiUrl: API_URL, verbose: false };

const STATUS = {
program_id: 1189,
name: "QA Test Program",
domain: "example.com",
dns_verified: false,
stripe_connected: false,
stripe_charges_enabled: false,
recent_events: { clicks_24h: 2, leads_24h: 1, sales_24h: 0 },
active_partners: 1,
pending_commissions: 0,
};

function respond(data: unknown) {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data }) }));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

describe("status and test with a key and no project config", () => {
let dir: string;
let cwd: string;
let envKey: string | undefined;
let out: string[];

beforeEach(() => {
cwd = process.cwd();
envKey = process.env.AFFITOR_API_KEY;
delete process.env.AFFITOR_API_KEY;
dir = mkdtempSync(join(tmpdir(), "affitor-key-only-"));
process.chdir(dir);
out = [];
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
vi.spyOn(process.stdout, "write").mockImplementation(((s: string) => (out.push(String(s)), true)) as never);
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
});

afterEach(() => {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
if (envKey === undefined) delete process.env.AFFITOR_API_KEY;
else process.env.AFFITOR_API_KEY = envKey;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("status reads the program named by AFFITOR_API_KEY and shows its domain", async () => {
process.env.AFFITOR_API_KEY = "aff_env_key";
const fetchMock = respond(STATUS);

await runStatus(flags);

const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { headers: Record<string, string> }];
expect(url).toBe(`${API_URL}/api/v1/cli/status`);
expect(init.headers.Authorization).toBe("Bearer aff_env_key");
const printed = out.join("\n");
expect(printed).toContain("example.com");
expect(printed).not.toContain("undefined");
});

it.each([
["with a project config, its domain wins", true],
["key only, the hostname of the website the API returns", false],
])("the DNS hint is a hostname even when the website has a path: %s", async (_label, withConfig) => {
if (withConfig) {
mkdirSync(join(dir, ".affitor"));
writeFileSync(join(dir, ".affitor", "config.json"), JSON.stringify({ version: 2, program_id: "1189", domain: "example.com", api_url: API_URL }));
}
process.env.AFFITOR_API_KEY = "aff_env_key";
respond({ ...STATUS, domain: "example.com/pricing/" });

await runStatus(flags);

const printed = out.join("\n");
expect(printed).toContain("t.example.com → track.affitor.com");
expect(printed).not.toContain("/pricing");
});

it.each(["HTTPS://Example.com/pricing/", "Http://example.com"])("key only, a scheme in any case is dropped: %s", async (site) => {
process.env.AFFITOR_API_KEY = "aff_env_key";
respond({ ...STATUS, domain: site });

await runStatus(flags);

expect(out.join("\n")).toContain("t.example.com → track.affitor.com");
});

it("no DNS hint when the API has no usable website", async () => {
process.env.AFFITOR_API_KEY = "aff_env_key";
respond({ ...STATUS, domain: "" });

await runStatus(flags);

const printed = out.join("\n");
expect(printed).not.toContain("CNAME");
expect(printed).not.toContain("undefined");
});

it("test sends the event for the program named by --api-key", async () => {
const fetchMock = respond({ received: true, event_id: 7, attributed: false });

await runTest("click", { ...flags, apiKey: "aff_flag_key" });

const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { headers: Record<string, string> }];
expect(url).toBe(`${API_URL}/api/v1/cli/test-event`);
expect(init.headers.Authorization).toBe("Bearer aff_flag_key");
});

it("with neither config nor key, status says how to use an existing program", async () => {
respond(STATUS);

await expect(runStatus(flags)).rejects.toThrow("exit:1");

expect(out.join("\n")).toContain("AFFITOR_API_KEY");
});
});
Loading