From c38c5d6ccb07b815cd874cfb123aa3524666bd99 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:38:17 -0700 Subject: [PATCH 1/6] test: affitor status and test run with an API key and no project config Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/tests/status-test-key-only.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/cli/tests/status-test-key-only.test.ts 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..b822764 --- /dev/null +++ b/packages/cli/tests/status-test-key-only.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } 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("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"); + }); +}); From 5b284e31ef2bf1883447994e601bc7aa98ae47b5 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:39:04 -0700 Subject: [PATCH 2/6] fix: affitor status and test run with an API key and no project config A program created in the dashboard has a 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 now run with --api-key or AFFITOR_API_KEY when there is no config, and status shows the domain the API returns (it printed config.domain, 'undefined' without it). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/commands/status.ts | 14 +++++++------- packages/cli/src/commands/test.ts | 10 +++++----- packages/cli/src/lib/api-client.ts | 6 +++--- packages/cli/src/lib/config.ts | 16 +++++++++++++++- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0c84173..aa9bd6a 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); @@ -63,7 +63,7 @@ export async function runStatus(flags: CLIFlags) { logger.banner(); logger.titledBox(status.name, [ - ` ${format.dim(config.domain)}`, + ` ${format.dim(status.domain)}`, ` ${format.dim("ID:")} ${status.program_id}`, "", ` ${format.bold("Integrations")}`, @@ -80,7 +80,7 @@ export async function runStatus(flags: CLIFlags) { // Action hints if (!status.dns_verified) { - logger.step(`DNS: Add CNAME t.${config.domain} → track.affitor.com`); + logger.step(`DNS: Add CNAME t.${status.domain} → track.affitor.com`); } if (!status.stripe_connected) { logger.step("Stripe: Connect it in the Affitor dashboard (program Settings, Connect Stripe)"); @@ -98,7 +98,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 { 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"; } From ae3c9d2f053a14baae3e169db85d2d84cc6be72e Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:45:08 -0700 Subject: [PATCH 3/6] test: affitor status prints a hostname in the DNS hint Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/tests/status-test-key-only.test.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/status-test-key-only.test.ts b/packages/cli/tests/status-test-key-only.test.ts index b822764..6071449 100644 --- a/packages/cli/tests/status-test-key-only.test.ts +++ b/packages/cli/tests/status-test-key-only.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runStatus } from "../src/commands/status"; @@ -78,6 +78,35 @@ describe("status and test with a key and no project config", () => { 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("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 }); From ce10a56b11ba943d3972d6f70dbad1dcd5376f0c Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:45:22 -0700 Subject: [PATCH 4/6] fix: affitor status keeps the config domain and prints a hostname in the DNS hint The API returns the website minus its scheme with the path kept, so a site like https://example.com/pricing/ made the hint t.example.com/pricing/. The config's domain is used whenever there is a config (as before); key-only runs reduce the API website to its hostname, and print no DNS hint when there is none. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/commands/status.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index aa9bd6a..0297e41 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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(status.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.${status.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)"); @@ -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(`https://${site.replace(/^https?:\/\//, "")}`).hostname; + } catch { + return ""; + } +} From 0998c23cedacc2f9aaf33e3cfca2e43c78fbb3e8 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:49:14 -0700 Subject: [PATCH 5/6] test: the DNS hint drops a scheme written in any case Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/tests/status-test-key-only.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/tests/status-test-key-only.test.ts b/packages/cli/tests/status-test-key-only.test.ts index 6071449..5f20190 100644 --- a/packages/cli/tests/status-test-key-only.test.ts +++ b/packages/cli/tests/status-test-key-only.test.ts @@ -96,6 +96,15 @@ describe("status and test with a key and no project config", () => { 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: "" }); From f5a78ab36193c6bf55f7c39d341fdd67cebcba51 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 13 Oct 2026 01:49:21 -0700 Subject: [PATCH 6/6] fix: the DNS hint parses the website with URL, scheme in any case Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/commands/status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0297e41..40b9e7e 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -123,7 +123,7 @@ export async function runStatus(flags: CLIFlags) { function hostnameOf(site: string | undefined): string { if (!site) return ""; try { - return new URL(`https://${site.replace(/^https?:\/\//, "")}`).hostname; + return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(site) ? site : `https://${site}`).hostname; } catch { return ""; }