From 7f7f5a6794a5731c6be12c9db9014943050038db Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 14 Jun 2026 17:18:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20affitor=20onboard=20=E2=80=94=20co?= =?UTF-8?q?mposed=20detect=20=E2=86=92=20wire=20=E2=86=92=20verify=20(G6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capstone verb that ties the agent loop into one command, so the Echoly-class stack (Fastify+Stripe) can be 1-shot. `affitor onboard`: detect stack + payment provider → install @affitor/sdk + inject browser tracker (reuses init wizard) → locate the Stripe webhook (webhook-detect) + pull the canonical recipe (@affitor/recipes) → inject affitor.trackSale + its import into the handler (DIFF PREVIEW + confirm; idempotent) → write AFFITOR_API_KEY (never overwrites) → VERIFY LOOP: fire the synthetic chain + poll readiness until integration_verified, print the blocking gate's next_action. SAFETY: never edits payment code silently. The webhook transform (injectStripeTrackSale) is conservative — it injects only when it finds constructEvent + exactly one checkout.session.completed + a clean `session` binding, and adds the `import { affitor }` so the result compiles; anything ambiguous returns `unrecognized` → the exact patch is PRINTED, never forced. Checkout-metadata is always printed (never auto-edited). --json/--yes for agents (no prompts; --json never auto-edits). 429 on the chain backs off (capped). api-client gains getReadiness() + runVerificationChain() (429 no-throw). init/wizard byte-stable. Build clean, suite 77/77. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/onboard.ts | 454 ++++++++++++++++++ packages/cli/src/lib/api-client.ts | 75 +++ packages/cli/src/lib/inject.ts | 144 ++++++ packages/cli/src/types.ts | 41 ++ .../cli/tests/inject-stripe-tracksale.test.ts | 201 ++++++++ packages/cli/tests/onboard-api.test.ts | 87 ++++ 7 files changed, 1004 insertions(+) create mode 100644 packages/cli/src/commands/onboard.ts create mode 100644 packages/cli/tests/inject-stripe-tracksale.test.ts create mode 100644 packages/cli/tests/onboard-api.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 349d0bd..e913ef3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ 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 { registerOnboardCommand } from "./commands/onboard.js"; import { registerSetupCommand } from "./commands/setup-stripe.js"; import { registerStatusCommand } from "./commands/status.js"; import { registerTestCommand } from "./commands/test.js"; @@ -59,6 +60,7 @@ registerLoginCommand(program); registerLogoutCommand(program); registerWhoamiCommand(program); registerInitCommand(program); +registerOnboardCommand(program); registerSetupCommand(program); registerStatusCommand(program); registerTestCommand(program); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts new file mode 100644 index 0000000..0937be9 --- /dev/null +++ b/packages/cli/src/commands/onboard.ts @@ -0,0 +1,454 @@ +import type { Command } from "commander"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { getRecipe, type Framework as RecipeFramework, type Mode } from "@affitor/recipes"; +import * as logger from "../lib/logger.js"; +import { format } from "../lib/logger.js"; +import { getFlags } from "../lib/flags.js"; +import { AffitorAPI, APIError, NetworkError } from "../lib/api-client.js"; +import { resolveApiKey } from "../lib/config.js"; +import { confirmAction } from "../lib/prompts.js"; +import { detectStack, type Framework } from "../lib/stack-detect.js"; +import { detectPaymentProvider } from "../lib/server-tracking.js"; +import { detectStripeWebhook } from "../lib/webhook-detect.js"; +import { injectStripeTrackSale } from "../lib/inject.js"; +import { runInstallWizard } from "../lib/wizard.js"; +import { DEFAULT_API_URL, type CLIFlags, type ReadinessResult } from "../types.js"; + +interface OnboardOpts { + apiKey?: string; + interactive?: boolean; + yes?: boolean; +} + +/** A single machine-readable step in the --json summary. */ +interface OnboardStep { + step: string; + status: "ok" | "skipped" | "manual" | "already" | "failed"; + detail?: string; +} + +export function registerOnboardCommand(program: Command) { + program + .command("onboard") + .description( + "Wire Affitor into this app end-to-end: detect → install browser tracking → inject the Stripe sale call → verify.", + ) + .option("--api-key ", "Program API key (overrides env / .affitor/.env)") + .option("--yes", "Auto-confirm all diffs (apply without prompting)", false) + .action(async (opts: OnboardOpts, cmd) => { + await runOnboard(opts, getFlags(cmd)); + }); +} + +/** Map our stack-detect framework to the recipe registry's framework union. */ +function toRecipeFramework(framework: Framework): RecipeFramework { + // The two unions are intentionally identical; this is a typed pass-through. + return framework as RecipeFramework; +} + +async function runOnboard(opts: OnboardOpts, flags: CLIFlags) { + const cwd = process.cwd(); + const interactive = !flags.noInteractive && opts.interactive !== false; + // --yes (or the global --auto-confirm, or --no-interactive for agents) + // applies diffs without prompting. + const autoConfirm = !!opts.yes || flags.autoConfirm || flags.noInteractive; + const apiUrl = flags.apiUrl ?? DEFAULT_API_URL; + const steps: OnboardStep[] = []; + + // ── (a) Resolve API key + program ── + const apiKey = resolveApiKey({ apiKey: opts.apiKey ?? flags.apiKey }, cwd); + if (!apiKey) { + logger.error( + "No API key found. Pass --api-key, set AFFITOR_API_KEY, or run `affitor init` first.", + ); + if (flags.json) logger.json({ error: "no_api_key" }); + process.exit(1); + } + + const api = new AffitorAPI({ apiUrl, apiKey }); + + if (!flags.json) { + logger.banner(); + logger.titledBox("Onboard", [ + "", + " Wiring Affitor into your app end-to-end.", + ` API key: ${format.dim(logger.maskApiKey(apiKey))}`, + "", + ]); + } + + // ── (b) Detect stack + payment provider ── + const stack = detectStack(cwd); + const provider = detectPaymentProvider(cwd); + steps.push({ + step: "detect", + status: "ok", + detail: `framework=${stack.framework}, provider=${provider}`, + }); + + if (!flags.json) { + logger.titledBox("Detected", [ + "", + ` Framework: ${stack.framework === "unknown" ? format.yellow("not detected") : format.green(stack.framework)}`, + ` Payment provider: ${provider === "unknown" ? format.yellow("not detected") : format.green(provider)}`, + "", + ]); + } + + // ── (c) Browser tracking — reuse init's wizard (install @affitor/sdk + + // inject via diff-preview+confirm + scaffold lib/affitor.ts). + // Skipped in --json mode (the wizard is interactive/printed output). ── + if (!flags.json) { + await runInstallWizard({ cwd, programId: "", apiUrl, autoConfirm }); + steps.push({ step: "browser_tracking", status: "ok", detail: "via install wizard" }); + } else { + steps.push({ step: "browser_tracking", status: "skipped", detail: "json mode" }); + } + + // ── (d) Server payment tracking — the new auto-edit (DIFF + confirm). ── + const saleStep = await wireServerSale({ + cwd, + framework: stack.framework, + provider, + interactive, + autoConfirm, + json: flags.json, + }); + steps.push(saleStep); + + // ── (f) Persist AFFITOR_API_KEY into .env / .env.local (diff-preview, never + // overwrite an existing value). ── + const envStep = writeApiKeyToEnv(cwd, apiKey, { autoConfirm, json: flags.json }); + steps.push(envStep); + + // ── (g) Verify loop: fire the chain, poll readiness. ── + const verify = await runVerifyLoop(api, { apiKey, apiUrl, json: flags.json }); + + // ── (h) Final summary. ── + if (flags.json) { + logger.json({ + program_id: verify.readiness?.program_id ?? null, + steps, + integration_verified: verify.integration_verified, + ...(verify.blocker ? { blocker: verify.blocker } : {}), + ...(verify.next_action ? { next_action: verify.next_action } : {}), + }); + return; + } + + if (verify.integration_verified) { + logger.titledBox("Verified", [ + "", + ` ${format.green("✓")} Integration verified — clicks, leads and sales are attributing.`, + "", + ]); + } else { + const lines = ["", ` ${format.yellow("⧗")} Not verified yet.`]; + if (verify.blocker) lines.push(` Blocked on: ${format.yellow(verify.blocker)}`); + if (verify.next_action) lines.push(` Next action: ${verify.next_action}`); + lines.push("", ` Re-run ${format.cyan("affitor onboard")} after fixing the blocker.`, ""); + logger.titledBox("Almost there", lines); + } +} + +// ─── (d) Server-side sale wiring ───────────────────────────────────── + +interface WireSaleArgs { + cwd: string; + framework: Framework; + provider: string; + interactive: boolean; + autoConfirm: boolean; + json: boolean; +} + +/** + * The safety-critical part: locate the Stripe webhook, run the PURE + * `injectStripeTrackSale` transform, and: + * - `injected` → show the DIFF and confirm before writing. + * - `already` → no-op (idempotent). + * - `unrecognized` → PRINT the exact patch (snippet + inject_target + file:line). + * - no webhook → PRINT the full recipe. + * NEVER auto-edits when the structure isn't cleanly recognized. + */ +async function wireServerSale(args: WireSaleArgs): Promise { + const { cwd, framework, provider, interactive, autoConfirm, json } = args; + + // Only Stripe has the auto-edit path (the recipe's sale snippet is keyed to + // the verified checkout.session.completed event object). Other providers → + // print the recipe (no reliable inject site). + // Use 's2s' mode so a sale snippet exists (Connect mode = metadata only). + const mode: Mode = "s2s"; + const recipe = getRecipe(toRecipeFramework(framework), provider === "stripe" ? "stripe" : "unknown", mode); + + if (provider !== "stripe") { + if (!json) { + printRecipe(recipe, null, "Server-side sale (printed — review and add)"); + } + return { step: "server_sale", status: "manual", detail: `provider=${provider}: printed recipe` }; + } + + const hook = detectStripeWebhook(cwd, framework); + + if (!hook) { + if (!json) { + printRecipe(recipe, null, "Stripe sale (no webhook found — add this where you verify webhooks)"); + } + return { step: "server_sale", status: "manual", detail: "no webhook handler found: printed recipe" }; + } + + const fileAbs = join(cwd, hook.file); + let original: string; + try { + original = readFileSync(fileAbs, "utf8"); + } catch { + if (!json) printRecipe(recipe, hook, "Stripe sale (couldn't read the webhook file — add manually)"); + return { step: "server_sale", status: "manual", detail: `unreadable ${hook.file}: printed recipe` }; + } + + // Compute the import specifier from the webhook file's directory to the + // scaffolded server client (lib/affitor.ts or src/lib/affitor.ts). + // Mirror the path logic from wizard.ts: prefer src/lib when src/ exists. + const clientDir = existsSync(join(cwd, "src")) ? join(cwd, "src", "lib") : join(cwd, "lib"); + const clientAbs = join(clientDir, "affitor.ts"); + const webhookDir = dirname(fileAbs); + let importSpecifier = relative(webhookDir, clientAbs).replace(/\.ts$/, ""); + // Ensure POSIX separators and a leading ./ or ../ + importSpecifier = importSpecifier.replace(/\\/g, "/"); + if (!importSpecifier.startsWith(".")) importSpecifier = `./${importSpecifier}`; + + const saleSnippet = recipe.sale?.snippet ?? ""; + const result = injectStripeTrackSale(original, { saleSnippet, importSpecifier }); + + if (result.status === "already") { + if (!json) logger.step(`${hook.file} already reports the sale — skipped`); + return { step: "server_sale", status: "already", detail: hook.file }; + } + + if (result.status === "unrecognized") { + // NEVER force-edit payment code we can't place confidently — print the patch. + if (!json) { + logger.warn( + `Found your Stripe webhook (${format.green(`${hook.file}:${hook.line}`)}) but couldn't place the sale call safely.`, + ); + printRecipe(recipe, hook, "Add the sale call yourself (exact patch)"); + } + return { + step: "server_sale", + status: "manual", + detail: `unrecognized shape in ${hook.file}: printed patch`, + }; + } + + // injected → DIFF + confirm before touching payment code. + if (!json) { + showDiff(hook.file, result.added); + } + const ok = + json || autoConfirm || (interactive && (await confirmAction(`Apply this change to ${hook.file}?`))); + if (!ok) { + if (!json) { + logger.step("Skipped. Add the sale call when ready:"); + printRecipe(recipe, hook, "Stripe sale (skipped — add manually)"); + } + return { step: "server_sale", status: "skipped", detail: `${hook.file}: user declined` }; + } + + // In --json mode we never edit files (no diff was shown to confirm). Treat it + // as a manual step so agents drive the edit explicitly. + if (json) { + return { step: "server_sale", status: "manual", detail: `${hook.file}: json mode (no auto-edit)` }; + } + + writeFileSync(fileAbs, result.content, "utf8"); + logger.success(`Injected affitor.trackSale into ${hook.file}`); + return { step: "server_sale", status: "ok", detail: hook.file }; +} + +/** + * (e) Always PRINT the metadata-propagation snippet — we never auto-edit the + * checkout-session-create call (it can't be located reliably). Plus the sale + * snippet + inject_target when a `hook` is known (the unrecognized/no-webhook + * fallbacks). Pure printing — touches no files. + */ +function printRecipe( + recipe: ReturnType, + hook: { file: string; line: number; handlerHint: string } | null, + title: string, +): void { + const lines: string[] = [""]; + + // (e) Metadata — ALWAYS printed, NEVER auto-edited. + lines.push(` ${format.dim("1) At checkout-session creation — plant attribution metadata:")}`); + lines.push(` ${format.dim(recipe.metadata.why)}`); + for (const l of recipe.metadata.snippet.split("\n")) lines.push(` ${format.cyan(l)}`); + lines.push(""); + + // Sale snippet + where to inject it. + if (recipe.sale) { + if (hook) { + lines.push(` ${format.dim(`2) Report the sale — in ${hook.file}:${hook.line}`)} ${format.dim(`(${hook.handlerHint})`)}`); + } else { + lines.push(` ${format.dim(`2) Report the sale — ${recipe.sale.inject_target}:`)}`); + } + for (const l of recipe.sale.snippet.split("\n")) lines.push(` ${format.cyan(l)}`); + lines.push(""); + } + + lines.push(` ${format.dim("Affitor never auto-edits your checkout/payment code — paste these in.")}`, ""); + logger.titledBox(title, lines); +} + +function showDiff(file: string, added: string[]): void { + logger.newline(); + logger.info(` ${format.dim("Proposed change to")} ${format.cyan(file)}${format.dim(":")}`); + for (const line of added) { + logger.info(` ${format.green("+ " + line)}`); + } + logger.newline(); +} + +// ─── (f) Write AFFITOR_API_KEY to .env / .env.local ────────────────── + +/** + * Persist the program key into the project's env file (.env.local preferred for + * Next, else .env). NEVER overwrites an existing AFFITOR_API_KEY value; if a + * different value is present we print a diff-style notice and leave it. Appends + * a new line when absent (diff-preview, then confirm unless autoConfirm). + */ +function writeApiKeyToEnv( + cwd: string, + apiKey: string, + opts: { autoConfirm: boolean; json: boolean }, +): OnboardStep { + const envName = existsSync(join(cwd, ".env.local")) ? ".env.local" : ".env"; + const envPath = join(cwd, envName); + const line = `AFFITOR_API_KEY=${apiKey}`; + + let content = ""; + if (existsSync(envPath)) { + content = readFileSync(envPath, "utf8"); + const existing = content.match(/^AFFITOR_API_KEY=(.*)$/m); + if (existing) { + if (existing[1].trim() === apiKey) { + if (!opts.json) logger.step(`${envName} already has AFFITOR_API_KEY — skipped`); + return { step: "env_key", status: "already", detail: envName }; + } + // Different value present — NEVER overwrite a secret. Print a notice. + if (!opts.json) { + logger.warn(`${envName} already has a different AFFITOR_API_KEY — left unchanged.`); + logger.step(`To use this key instead, set: ${line}`); + } + return { step: "env_key", status: "manual", detail: `${envName}: existing value kept` }; + } + } + + if (!opts.json) { + logger.newline(); + logger.info(` ${format.dim(`Add to ${envName}:`)}`); + logger.info(` ${format.green("+ " + line)}`); + logger.newline(); + } + + const needsNewline = content.length > 0 && !content.endsWith("\n"); + const block = `${needsNewline ? "\n" : ""}${line}\n`; + writeFileSync(envPath, content + block, "utf8"); + if (!opts.json) logger.success(`Wrote AFFITOR_API_KEY to ${envName}`); + return { step: "env_key", status: "ok", detail: envName }; +} + +// ─── (g) Verification loop ─────────────────────────────────────────── + +interface VerifyResult { + integration_verified: boolean; + blocker?: string | null; + next_action?: string | null; + readiness?: ReadinessResult; +} + +const POLL_ATTEMPTS = 6; +const POLL_DELAY_MS = 2000; +/** Cap the total backoff wait so a rate-limited chain can never hang forever. */ +const MAX_BACKOFF_MS = 30_000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Fire the synthetic chain, then poll readiness up to POLL_ATTEMPTS times until + * `integration_verified` (or a gate blocks). Honors 429: reads + * `retry_after_seconds`, backs off (capped at MAX_BACKOFF_MS), and never hammers. + */ +async function runVerifyLoop( + api: AffitorAPI, + opts: { apiKey: string; apiUrl: string; json: boolean }, +): Promise { + if (!opts.json) { + logger.titledBox("Verify", ["", " Firing the synthetic click → lead → sale chain…", ""]); + } + + // Fire the chain. On 429, back off once (capped) then continue to polling. + try { + const chain = await api.runVerificationChain({ apiKey: opts.apiKey, apiUrl: opts.apiUrl }); + if (chain.rate_limited) { + const wait = Math.min((chain.retry_after_seconds ?? 5) * 1000, MAX_BACKOFF_MS); + if (!opts.json) { + logger.step(`Rate limited — backing off ${Math.ceil(wait / 1000)}s (won't hammer).`); + } + await sleep(wait); + } else if (!opts.json && chain.verdict) { + const v = chain.verdict; + logger.step( + `Chain: click ${tick(v.click)} · lead ${tick(v.lead)} · sale ${tick(v.sale)}`, + ); + } + } catch (err) { + if (!opts.json) { + const msg = err instanceof APIError || err instanceof NetworkError ? err.message : (err as Error).message; + logger.warn(`Verification chain didn't run: ${msg}`); + } + } + + // Poll readiness until verified or a gate blocks. + let last: ReadinessResult | undefined; + for (let attempt = 1; attempt <= POLL_ATTEMPTS; attempt++) { + try { + last = await api.getReadiness({ apiKey: opts.apiKey, apiUrl: opts.apiUrl }); + } catch (err) { + if (!opts.json) { + const msg = err instanceof APIError ? err.message : (err as Error).message; + logger.step(`Readiness check failed (attempt ${attempt}/${POLL_ATTEMPTS}): ${msg}`); + } + await sleep(POLL_DELAY_MS); + continue; + } + + if (last.integration_verified) { + return { integration_verified: true, readiness: last }; + } + + const gate = blockingGate(last); + if (!opts.json && gate) { + logger.step(`Gate "${gate.label ?? gate.id}" not passed (attempt ${attempt}/${POLL_ATTEMPTS}).`); + } + + if (attempt < POLL_ATTEMPTS) await sleep(POLL_DELAY_MS); + } + + const gate = last ? blockingGate(last) : undefined; + return { + integration_verified: false, + blocker: gate?.label ?? gate?.id ?? last?.blocker ?? null, + next_action: gate?.next_action ?? last?.next_action ?? null, + readiness: last, + }; +} + +/** The first failing gate (the blocker), if the readiness payload lists gates. */ +function blockingGate(r: ReadinessResult) { + return r.gates?.find((g) => !g.passed); +} + +function tick(v: boolean | undefined): string { + return v ? format.green("✓") : format.yellow("…"); +} diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index fc4c9ad..9975b13 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -5,6 +5,8 @@ import type { ProgramStatus, ProgramSummary, TestEventResult, + ReadinessResult, + VerificationChainResult, } from "../types.js"; import * as logger from "./logger.js"; import { resolveApiKey } from "./config.js"; @@ -114,6 +116,79 @@ export class AffitorAPI { }); } + // ─── Onboarding / verification endpoints ──────────────────────── + + /** + * GET /api/v1/programs/me/readiness — the per-program 5-gate verdict + * (Bearer key). `integration_verified` flips true when every gate passes; + * the blocking gate's `next_action` tells the caller what to fix. + */ + async getReadiness(opts: { apiKey?: string; apiUrl?: string } = {}): Promise { + return this.request("/api/v1/programs/me/readiness", { + apiKey: opts.apiKey, + apiUrl: opts.apiUrl, + }); + } + + /** + * POST /api/v1/cli/test-event {type:'chain'} — fire the synthetic + * click→lead→sale chain through the REAL attribution pipeline (isolated + * is_test rows). Mirrors the MCP `fireVerificationChain` contract exactly. + * + * Does NOT use the shared `request()` helper because that throws on 429. A + * 429 here is expected (the chain is rate-limited to 10/program/hour) and the + * caller must read `retry_after_seconds` and back off — so on a non-2xx this + * returns the parsed body (with `rate_limited` + `retry_after_seconds` merged + * in for 429) instead of throwing. Only a network/parse failure throws. + */ + async runVerificationChain( + opts: { apiKey?: string; apiUrl?: string } = {}, + ): Promise { + const apiUrl = opts.apiUrl ?? this.apiUrl; + const key = opts.apiKey ?? this.apiKey; + const url = `${apiUrl}/api/v1/cli/test-event`; + + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "affitor-cli/0.2.0", + }; + if (key) headers["Authorization"] = `Bearer ${key}`; + + logger.debug(`POST ${url} {type:'chain'}`); + + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ type: "chain" }), + }); + } catch (err) { + throw new NetworkError((err as Error).message); + } + + const body = (await res.json().catch(() => ({}))) as Record; + + if (res.ok) { + // Server returns { data: { verdict, attributed, ... } } or the bare object. + return (body.data ?? body) as VerificationChainResult; + } + + // Non-2xx (incl. 429): DO NOT throw — surface the parsed body so the caller + // can read retry_after_seconds and back off. Mirror MCP fireVerificationChain. + const errObj = (body.error ?? {}) as { code?: string; retry_after_seconds?: number }; + const retryHeader = res.headers.get("Retry-After"); + const retryAfter = + errObj.retry_after_seconds ?? (retryHeader ? parseInt(retryHeader, 10) : undefined); + + return { + ...(body as VerificationChainResult), + http_status: res.status, + rate_limited: res.status === 429 || errObj.code === "rate_limited", + ...(retryAfter !== undefined ? { retry_after_seconds: retryAfter } : {}), + }; + } + private async request(path: string, opts: RequestOptions = {}): Promise { const url = `${opts.apiUrl ?? this.apiUrl}${path}`; const key = opts.apiKey ?? this.apiKey; diff --git a/packages/cli/src/lib/inject.ts b/packages/cli/src/lib/inject.ts index 4fb05df..45f4226 100644 --- a/packages/cli/src/lib/inject.ts +++ b/packages/cli/src/lib/inject.ts @@ -95,3 +95,147 @@ export function injectPagesApp(content: string, importSpecifier: string): Inject return { content: injected, status: "injected", added: [importLine, `<${TRACKER_MARKER} />`] }; } + +// ── Server-side: inject `affitor.trackSale(...)` into a Stripe webhook ── +// +// This edits PAYMENT code, so it is deliberately conservative: it only injects +// when it can recognize a clean `checkout.session.completed` shape with a single +// obvious session binding. Anything ambiguous → `unrecognized`, and the caller +// PRINTS the exact patch instead of forcing an edit. The transform is pure (no +// fs) and idempotent (a trackSale / @affitor/sdk/server marker → `already`). + +/** Markers that mean a trackSale call (or the server import) is already wired. */ +const TRACK_SALE_MARKER = "affitor.trackSale("; +const SERVER_IMPORT_MARKER = "@affitor/sdk/server"; + +/** The canonical Stripe webhook-verification call (Node SDK) we anchor on. */ +const CONSTRUCT_EVENT_NEEDLE = "stripe.webhooks.constructEvent"; + +export interface InjectStripeOpts { + /** + * The provider sale snippet from `@affitor/recipes` (e.g. + * `getRecipe(framework, 'stripe', 's2s').sale!.snippet`). The SDK import line + * (`import { Affitor } from '@affitor/sdk/server';`) is stripped before + * insertion — the inserted body is just the `affitor.trackSale({...})` call, + * indented to match the matched session binding. + */ + saleSnippet: string; + /** + * Module specifier for the `affitor` client import — e.g. `'../lib/affitor'` + * or `'@/lib/affitor'`. When provided and status is 'injected', the transform + * also adds `import { affitor } from '';` at the top of the + * file (idempotent: skipped if that line is already present). Included in + * `added[]` so the diff preview shows it. + */ + importSpecifier?: string; + /** + * Optional indent override (spaces). When omitted, the indent of the matched + * session-binding line is reused so the inserted call lines up with its block. + */ + indent?: string; +} + +/** Drop the recipe's leading `@affitor/sdk/server` import + its blank line. */ +function saleCallBody(snippet: string): string { + return snippet + .replace(/^import \{ Affitor \} from '@affitor\/sdk\/server';\n+/, "") + .trimEnd(); +} + +/** Re-indent a multi-line snippet body to a given base indent. */ +function indentBody(body: string, indent: string): string { + return body + .split("\n") + .map((line) => (line.length ? indent + line : line)) + .join("\n"); +} + +/** The import line for the affitor client given a module specifier. */ +function affitorImportLine(importSpecifier: string): string { + return `import { affitor } from '${importSpecifier}';`; +} + +/** + * Insert `affitor.trackSale(...)` into a Stripe webhook handler, right after the + * verified `checkout.session.completed` session object is bound. + * + * CONSERVATIVE BY DESIGN — only injects when ALL of these hold (else returns + * `unrecognized` so the caller prints the patch instead of editing payment code): + * 1. The file contains `stripe.webhooks.constructEvent` (it really is a + * verified Stripe webhook — we never edit an unverified handler). + * 2. There is a single `checkout.session.completed` reference to anchor on + * (the recipe's stripe snippet reads `session.*`, so the sale belongs in + * that case). Zero or multiple matches → unrecognized (ambiguous). + * 3. After that anchor, exactly one line binds the event object to a name we + * can reuse — `const session = event.data.object …` (or `= session …`). + * The trackSale call is inserted on the line after that binding, at the + * binding's own indentation. No binding, or an unexpected variable name → + * unrecognized. + * + * Returns `already` when a `affitor.trackSale(` / `@affitor/sdk/server` marker is + * already present (idempotent re-runs do nothing). + */ +export function injectStripeTrackSale(content: string, opts: InjectStripeOpts): InjectResult { + // Idempotent: never double-inject. + // Check for trackSale marker, server import marker, OR the affitor client import + // (the specifier we would add) — any of these mean the file is already wired. + const clientImportLine = opts.importSpecifier ? affitorImportLine(opts.importSpecifier) : null; + if ( + content.includes(TRACK_SALE_MARKER) || + content.includes(SERVER_IMPORT_MARKER) || + (clientImportLine && content.includes(clientImportLine)) + ) { + return { content, status: "already", added: [] }; + } + + // (1) Must be a verified Stripe webhook — anchor on constructEvent. + if (!content.includes(CONSTRUCT_EVENT_NEEDLE)) { + return { content, status: "unrecognized", added: [] }; + } + + // (2) Exactly one checkout.session.completed reference (the sale anchor). + // Zero (some other event) or many (ambiguous which case) → bail. + const completedRe = /checkout\.session\.completed/g; + const completedMatches = content.match(completedRe); + if (!completedMatches || completedMatches.length !== 1) { + return { content, status: "unrecognized", added: [] }; + } + const anchorIdx = content.indexOf("checkout.session.completed"); + + // (3) After the anchor, find a single clean session binding to insert after. + // Accept `const session = event.data.object …` or `const session = session …` + // (cast variants). The variable MUST be named `session` — the recipe's stripe + // snippet reads `session.client_reference_id` / `session.amount_total` / + // `session.id`, so any other name would not compile. + const after = content.slice(anchorIdx); + const bindingRe = /^([ \t]*)const session\s*=\s*(?:event\.data\.object|session)\b[^\n]*$/m; + const bindingMatch = after.match(bindingRe); + if (!bindingMatch || bindingMatch.index === undefined) { + return { content, status: "unrecognized", added: [] }; + } + + // Absolute offset of the end of the binding line (insertion point). + const bindingLineStart = anchorIdx + bindingMatch.index; + const bindingLineEnd = bindingLineStart + bindingMatch[0].length; + + const indent = opts.indent ?? bindingMatch[1] ?? " "; + const body = saleCallBody(opts.saleSnippet); + if (!body.includes(TRACK_SALE_MARKER)) { + // The provided snippet isn't a trackSale call — refuse rather than guess. + return { content, status: "unrecognized", added: [] }; + } + + const block = indentBody(body, indent); + const insertion = `\n${indent}// Affitor: report the sale (auto-added by \`affitor onboard\`)\n${block}`; + const withSale = content.slice(0, bindingLineEnd) + insertion + content.slice(bindingLineEnd); + + // Also add the affitor client import when a specifier was provided. + let finalContent = withSale; + const added = insertion.split("\n").filter((l) => l.trim().length > 0); + if (clientImportLine) { + finalContent = addImport(withSale, clientImportLine); + added.unshift(clientImportLine); + } + + return { content: finalContent, status: "injected", added }; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 959f54e..2434514 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -100,6 +100,47 @@ export interface TestEventResult { message: string; } +/** + * Per-gate readiness verdict from GET /api/v1/programs/me/readiness. + * `integration_verified` flips true once all gates pass; `blocker`/`next_action` + * point at the first failing gate so the CLI can tell the user what to fix. + */ +export interface ReadinessGate { + id: string; + label?: string; + passed: boolean; + next_action?: string; +} + +export interface ReadinessResult { + integration_verified: boolean; + gates?: ReadinessGate[]; + blocker?: string | null; + next_action?: string | null; + [key: string]: unknown; +} + +/** + * Result of the synthetic verification chain (POST /api/v1/cli/test-event + * {type:'chain'}). On a 2xx the server returns `{ data: { verdict, attributed, + * ... } }`. On a 429 the client returns the parsed rate-limit body instead of + * throwing, so the caller can read `retry_after_seconds` and back off. + */ +export interface VerificationVerdict { + click?: boolean; + lead?: boolean; + sale?: boolean; +} + +export interface VerificationChainResult { + verdict?: VerificationVerdict; + attributed?: boolean; + rate_limited?: boolean; + retry_after_seconds?: number; + error?: { code?: string; retry_after_seconds?: number; message?: string }; + [key: string]: unknown; +} + export interface CLIFlags { json: boolean; noInteractive: boolean; diff --git a/packages/cli/tests/inject-stripe-tracksale.test.ts b/packages/cli/tests/inject-stripe-tracksale.test.ts new file mode 100644 index 0000000..89be75b --- /dev/null +++ b/packages/cli/tests/inject-stripe-tracksale.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { getRecipe } from "@affitor/recipes"; +import { injectStripeTrackSale } from "../src/lib/inject"; + +// The canonical Stripe sale snippet, sourced from the recipe registry (the same +// thing `affitor onboard` injects). Body reads `session.*`. +const SALE_SNIPPET = getRecipe("fastify", "stripe", "s2s").sale!.snippet; + +// Import specifier as onboard.ts would compute for a webhook at +// src/app/api/webhooks/stripe/route.ts when the client lives at src/lib/affitor.ts. +const IMPORT_SPECIFIER = "../../../lib/affitor"; +const IMPORT_LINE = `import { affitor } from '${IMPORT_SPECIFIER}';`; + +// A clean Fastify webhook: verifies the event, switches on type, binds +// `const session = event.data.object` in the checkout.session.completed case. +const CLEAN_FASTIFY = `import Fastify from 'fastify'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_KEY!); +const app = Fastify(); + +app.post('/webhooks/stripe', async (req, reply) => { + const sig = req.headers['stripe-signature'] as string; + const event = stripe.webhooks.constructEvent(req.rawBody, sig, secret); + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + await fulfill(session); + break; + } + } + + reply.send({ received: true }); +}); +`; + +describe("injectStripeTrackSale — injected (clean shape)", () => { + it("inserts trackSale right after the session binding, at its indent", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("injected"); + expect(r.content).toContain("await affitor.trackSale({"); + expect(r.content).toContain("Affitor: report the sale"); + expect(r.added.length).toBeGreaterThan(0); + + // The call lands AFTER the session binding (so `session` is in scope) and + // BEFORE the existing fulfill() call. + const bindingIdx = r.content.indexOf("const session = event.data.object"); + const saleIdx = r.content.indexOf("await affitor.trackSale({"); + const fulfillIdx = r.content.indexOf("await fulfill(session)"); + expect(bindingIdx).toBeLessThan(saleIdx); + expect(saleIdx).toBeLessThan(fulfillIdx); + + // The inserted call matches the session binding's indentation (6 spaces). + expect(r.content).toMatch(/\n {6}await affitor\.trackSale\(\{/); + }); + + it("adds the affitor import line to content and added[] when importSpecifier is given", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + expect(r.status).toBe("injected"); + // Import line present in the transformed content. + expect(r.content).toContain(IMPORT_LINE); + // Import line appears before the trackSale call. + expect(r.content.indexOf(IMPORT_LINE)).toBeLessThan(r.content.indexOf("await affitor.trackSale({")); + // Import line is the first entry in added[]. + expect(r.added[0]).toBe(IMPORT_LINE); + }); + + it("does not add an import when importSpecifier is omitted", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("injected"); + // No affitor client import added (only the trackSale call block). + expect(r.content).not.toContain("import { affitor } from"); + expect(r.added.every((l) => !l.startsWith("import { affitor }"))).toBe(true); + }); + + it("does not strip the original code (only adds the sale block)", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { saleSnippet: SALE_SNIPPET }); + expect(r.content).toContain("stripe.webhooks.constructEvent"); + expect(r.content).toContain("await fulfill(session)"); + expect(r.content).toContain("reply.send({ received: true })"); + }); + + it("respects an explicit indent override", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { saleSnippet: SALE_SNIPPET, indent: "" }); + expect(r.status).toBe("injected"); + expect(r.content).toMatch(/\nawait affitor\.trackSale\(\{/); + }); +}); + +describe("injectStripeTrackSale — already (idempotent)", () => { + it("is a no-op when affitor.trackSale is already present", () => { + const once = injectStripeTrackSale(CLEAN_FASTIFY, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }).content; + const twice = injectStripeTrackSale(once, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + expect(twice.status).toBe("already"); + expect(twice.content).toBe(once); + expect(twice.added).toHaveLength(0); + }); + + it("idempotent re-run has exactly ONE import line and ONE trackSale call", () => { + const once = injectStripeTrackSale(CLEAN_FASTIFY, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }).content; + const twice = injectStripeTrackSale(once, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + // Content is unchanged — exactly one of each. + const importCount = (twice.content.match(new RegExp(IMPORT_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length; + const saleCount = (twice.content.match(/affitor\.trackSale\(/g) ?? []).length; + expect(importCount).toBe(1); + expect(saleCount).toBe(1); + expect(twice.added).toHaveLength(0); + }); + + it("treats an @affitor/sdk/server import as already-wired", () => { + const withImport = `import { Affitor } from '@affitor/sdk/server';\n${CLEAN_FASTIFY}`; + const r = injectStripeTrackSale(withImport, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("already"); + expect(r.content).toBe(withImport); + }); + + it("treats an existing affitor client import line as already-wired", () => { + // If the import specifier line is already in the file, skip (even without trackSale). + const withClientImport = `${IMPORT_LINE}\n${CLEAN_FASTIFY}`; + const r = injectStripeTrackSale(withClientImport, { + saleSnippet: SALE_SNIPPET, + importSpecifier: IMPORT_SPECIFIER, + }); + expect(r.status).toBe("already"); + expect(r.content).toBe(withClientImport); + expect(r.added).toHaveLength(0); + }); +}); + +describe("injectStripeTrackSale — unrecognized (conservative, prints patch)", () => { + it("bails when there is no constructEvent (not a verified webhook)", () => { + const noVerify = `app.post('/webhooks/stripe', (req, reply) => { + const session = event.data.object; + reply.send({ ok: true }); +}); +`; + const r = injectStripeTrackSale(noVerify, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("unrecognized"); + expect(r.content).toBe(noVerify); + }); + + it("bails when checkout.session.completed is absent (different event)", () => { + const otherEvent = `const event = stripe.webhooks.constructEvent(body, sig, secret); +if (event.type === 'invoice.paid') { + const invoice = event.data.object; + handle(invoice); +} +`; + const r = injectStripeTrackSale(otherEvent, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("unrecognized"); + }); + + it("bails when checkout.session.completed appears more than once (ambiguous)", () => { + const ambiguous = `const event = stripe.webhooks.constructEvent(body, sig, secret); +// We branch on checkout.session.completed in two places: +if (event.type === 'checkout.session.completed') { + const session = event.data.object; + a(session); +} +function isCompleted(t) { return t === 'checkout.session.completed'; } +`; + const r = injectStripeTrackSale(ambiguous, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("unrecognized"); + }); + + it("bails when there is no clean `const session = …` binding to anchor on", () => { + // constructEvent + a single completed reference, but the object is bound to + // a name we don't recognize — too risky to place the session-keyed call. + const oddBinding = `const event = stripe.webhooks.constructEvent(body, sig, secret); +if (event.type === 'checkout.session.completed') { + const obj = event.data.object; + fulfill(obj); +} +`; + const r = injectStripeTrackSale(oddBinding, { saleSnippet: SALE_SNIPPET }); + expect(r.status).toBe("unrecognized"); + expect(r.content).toBe(oddBinding); + }); + + it("bails when the provided snippet is not a trackSale call", () => { + const r = injectStripeTrackSale(CLEAN_FASTIFY, { saleSnippet: "console.log('nope');" }); + expect(r.status).toBe("unrecognized"); + expect(r.content).toBe(CLEAN_FASTIFY); + }); +}); diff --git a/packages/cli/tests/onboard-api.test.ts b/packages/cli/tests/onboard-api.test.ts new file mode 100644 index 0000000..d075982 --- /dev/null +++ b/packages/cli/tests/onboard-api.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AffitorAPI } from "../src/lib/api-client"; + +/** + * Stub global fetch with a single response. `headers` is an optional map read + * back through a `.get()` shim (mirrors the real Headers API the client uses). + */ +function stubFetch(status: number, body: unknown, headers: Record = {}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (k: string) => headers[k] ?? headers[k.toLowerCase()] ?? null }, + json: async () => body, + })), + ); +} + +const api = new AffitorAPI({ apiUrl: "http://test.local", apiKey: "key_test" }); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("AffitorAPI.getReadiness", () => { + it("returns the readiness verdict on 200", async () => { + stubFetch(200, { + data: { + integration_verified: false, + gates: [{ id: "click", passed: true }, { id: "sale", passed: false, next_action: "Send a sale" }], + next_action: "Send a sale", + }, + }); + const r = await api.getReadiness(); + expect(r.integration_verified).toBe(false); + expect(r.gates).toHaveLength(2); + expect(r.next_action).toBe("Send a sale"); + }); + + it("unwraps a bare (non-data-wrapped) body too", async () => { + stubFetch(200, { integration_verified: true }); + const r = await api.getReadiness(); + expect(r.integration_verified).toBe(true); + }); + + it("throws (APIError) on a non-2xx readiness response", async () => { + stubFetch(401, { error: "Invalid token" }); + await expect(api.getReadiness()).rejects.toMatchObject({ message: "Invalid token" }); + }); +}); + +describe("AffitorAPI.runVerificationChain", () => { + it("returns the verdict from a 2xx { data } envelope", async () => { + stubFetch(200, { data: { verdict: { click: true, lead: true, sale: true }, attributed: true } }); + const r = await api.runVerificationChain(); + expect(r.verdict).toEqual({ click: true, lead: true, sale: true }); + expect(r.attributed).toBe(true); + expect(r.rate_limited).toBeUndefined(); + }); + + it("does NOT throw on 429 — returns rate_limited + retry_after_seconds from the body", async () => { + stubFetch( + 429, + { error: { code: "rate_limited", retry_after_seconds: 42 } }, + { "Retry-After": "42" }, + ); + const r = await api.runVerificationChain(); + expect(r.rate_limited).toBe(true); + expect(r.retry_after_seconds).toBe(42); + expect(r.http_status).toBe(429); + }); + + it("falls back to the Retry-After header when the body omits retry_after_seconds", async () => { + stubFetch(429, { error: { code: "rate_limited" } }, { "Retry-After": "7" }); + const r = await api.runVerificationChain(); + expect(r.rate_limited).toBe(true); + expect(r.retry_after_seconds).toBe(7); + }); + + it("flags other non-2xx without throwing (so the caller can decide)", async () => { + stubFetch(500, { error: { message: "boom" } }); + const r = await api.runVerificationChain(); + expect(r.rate_limited).toBe(false); + expect(r.http_status).toBe(500); + }); +});