diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0c84173..40b9e7e 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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"; @@ -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); @@ -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); @@ -56,6 +56,10 @@ 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); @@ -63,7 +67,7 @@ export async function runStatus(flags: CLIFlags) { logger.banner(); logger.titledBox(status.name, [ - ` ${format.dim(config.domain)}`, + ` ${format.dim(domain)}`, ` ${format.dim("ID:")} ${status.program_id}`, "", ` ${format.bold("Integrations")}`, @@ -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)"); @@ -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 { @@ -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 ""; + } +} diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index aa4bbf1..daef104 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -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"; @@ -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); @@ -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, }); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index c79b150..100c135 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -114,9 +114,9 @@ export class AffitorAPI { }); } - async getStatus(programId: string): Promise { + async getStatus(programId?: string): Promise { return this.request( - `/api/v1/cli/status?program_id=${programId}`, + programId ? `/api/v1/cli/status?program_id=${programId}` : "/api/v1/cli/status", ); } @@ -141,7 +141,7 @@ export class AffitorAPI { } async sendTestEvent(data: { - program_id: string; + program_id?: string; event_type: "click" | "lead" | "sale"; }): Promise { return this.request("/api/v1/cli/test-event", { diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index f58ba21..a876802 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -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)) { @@ -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"; } diff --git a/packages/cli/tests/status-test-key-only.test.ts b/packages/cli/tests/status-test-key-only.test.ts new file mode 100644 index 0000000..5f20190 --- /dev/null +++ b/packages/cli/tests/status-test-key-only.test.ts @@ -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 }]; + 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 }]; + 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"); + }); +});