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
20 changes: 4 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog — affitor (CLI)

## 0.4.3
- `affitor setup stripe` now points to the dashboard (program Settings, Connect Stripe) and exits 1 (`{"error": "stripe_setup_moved"}` with `--json`). The old flow could not complete: its token exchange needed Affitor's platform secret, the API that saved the connection is retired (410), and the webhook it created pointed at a URL the API does not serve. It no longer creates anything on your Stripe account. The unused `stripe` dependency is removed.

## 0.4.2
- Security: the secrets file is written with owner-only permissions, and login URLs are opened safely (#26).
- API key rotation: warnings for a key that must be replaced, a clear error for a retired key, and the `Deprecation`/`Sunset`/`Link` headers are read so a retired key is never silent (#27, #30). A refused key points to Regenerate, not to `affitor init` (#28).
Expand Down
20 changes: 4 additions & 16 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ CLI-native affiliate tracking. Connect Stripe, track commissions, manage partner
# Create your affiliate program
npx affitor init

# Connect Stripe for automatic payment tracking
npx affitor setup stripe
# Connect Stripe in the Affitor dashboard: program Settings, Connect Stripe

# Check program health
npx affitor status
Expand All @@ -26,7 +25,7 @@ npx affitor test sale
|---|---|
| `affitor init` | Create a new affiliate program. Interactive prompts or `--no-interactive` with flags. |
| `affitor onboard` | Wire Affitor into an existing app end-to-end: detect → install browser tracking → inject the Stripe sale call → verify. |
| `affitor setup stripe` | Connect Stripe via OAuth. Auto-configures webhooks for payment tracking. |
| `affitor setup stripe` | Points you to the dashboard, where Stripe is connected (program Settings, Connect Stripe). Exits 1. |
| `affitor setup dns` | Set up DNS CNAME tracking (coming soon). |
| `affitor status` | Show program health: DNS, Stripe connection, recent events. |
| `affitor test [type]` | Send a test event (`click`, `lead`, or `sale`). |
Expand Down Expand Up @@ -92,24 +91,13 @@ Running `npx affitor init` creates:
skills.md -- AI agent instructions
```

## Stripe Auto-Connect
## Stripe

`npx affitor setup stripe` automates the entire Stripe integration:

1. Opens Stripe Connect OAuth in your browser
2. Creates webhook endpoint on your Stripe account
3. Configures event listeners for:
- `customer.created` -- lead tracking
- `checkout.session.completed` -- sale tracking
- `invoice.paid` -- recurring commissions
- `invoice.payment_failed` -- failed payment alerts
- `charge.refunded` -- automatic commission clawback
- `customer.subscription.deleted` -- churn tracking
Stripe is connected from the Affitor dashboard (program Settings, Connect Stripe), not from the CLI. Affitor's own webhook then records sales and renewals; add Affitor metadata to your checkout so sales are attributed reliably.

## Requirements

- Node.js >= 18
- Stripe account (for `setup stripe`)

## License

Expand Down
3 changes: 1 addition & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "affitor",
"version": "0.4.2",
"version": "0.4.3",
"description": "CLI-native affiliate tracking. Connect Stripe, track commissions, manage partners — all from your terminal.",
"type": "module",
"bin": {
Expand Down Expand Up @@ -44,7 +44,6 @@
"@inquirer/prompts": "^7.5.0",
"picocolors": "^1.1.1",
"open": "^10.1.0",
"stripe": "^17.7.0",
"@affitor/recipes": "^0.1.0"
},
"devDependencies": {
Expand Down
185 changes: 19 additions & 166 deletions packages/cli/src/commands/setup-stripe.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import type { Command } from "commander";
import * as logger from "../lib/logger.js";
import { format } from "../lib/logger.js";
import { readConfig, updateConfig, writeSecrets, readSecrets, ConfigNotFoundError } from "../lib/config.js";
import { runStripeOAuth, StripeOAuthError } from "../lib/stripe-oauth.js";
import { AffitorAPI, APIError, NetworkError } from "../lib/api-client.js";
import { readConfig, ConfigNotFoundError } from "../lib/config.js";
import { getFlags } from "../lib/flags.js";
import type { CLIFlags } from "../types.js";

/**
* Stripe is connected from the dashboard. The CLI flow this replaced could not work: its token
* exchange needs Affitor's platform secret, the CMS endpoint that saved the connection answers
* 410 Gone (W37-1690), and the webhook it created pointed at a URL the CMS does not serve.
*/
export const STRIPE_SETUP_MOVED =
"Stripe is connected from the Affitor dashboard, not the CLI: open the program, go to Settings, and use Connect Stripe.";

export function registerSetupCommand(program: Command) {
const setup = program
.command("setup")
.description("Set up integrations (stripe, dns)");

setup
.command("stripe")
.description("Connect Stripe for automatic payment tracking")
.option("--stripe-client-id <id>", "Stripe Connect client ID override")
.option("--stripe-secret-key <key>", "Stripe secret key (for webhook creation)")
.action(async (opts, cmd) => {
await runSetupStripe(opts, getFlags(cmd));
.description("Connect Stripe (points you to the dashboard)")
.allowUnknownOption()
.allowExcessArguments()
.action((_opts, cmd) => {
runSetupStripe(getFlags(cmd));
});

setup
Expand All @@ -29,163 +35,10 @@ export function registerSetupCommand(program: Command) {
});
}

async function runSetupStripe(
opts: {
stripeClientId?: string;
stripeSecretKey?: string;
},
flags: CLIFlags,
) {
let config;
try {
config = readConfig();
} catch (err) {
if (err instanceof ConfigNotFoundError) {
logger.error(err.message);
if (flags.json) logger.json({ error: "no_config" });
process.exit(1);
}
throw err;
}

// Check if already connected via secrets or legacy config
const secrets = readSecrets();
if (secrets?.stripe_account_id || config.stripe_connected) {
const accountId = secrets?.stripe_account_id ?? config.stripe_account_id;
logger.warn("Stripe is already connected.");
logger.info(` Account: ${accountId}`);
logger.info(" To reconnect, disconnect first via the dashboard.");
if (flags.json) {
logger.json({
status: "already_connected",
stripe_account_id: accountId,
});
}
process.exit(0);
}

const clientId =
opts.stripeClientId ??
process.env.STRIPE_CONNECT_CLIENT_ID ??
process.env.AFFITOR_STRIPE_CLIENT_ID;

if (!clientId) {
logger.error(
"Stripe Connect client ID not configured.\n" +
" Set STRIPE_CONNECT_CLIENT_ID or AFFITOR_STRIPE_CLIENT_ID environment variable,\n" +
" or pass --stripe-client-id <id>.",
);
if (flags.json) logger.json({ error: "missing_stripe_client_id" });
process.exit(1);
}

const stripeSecretKey =
opts.stripeSecretKey ??
process.env.STRIPE_SECRET_KEY ??
process.env.AFFITOR_STRIPE_SECRET_KEY;

if (!stripeSecretKey) {
logger.error(
"Stripe secret key not configured.\n" +
" Set STRIPE_SECRET_KEY or AFFITOR_STRIPE_SECRET_KEY environment variable,\n" +
" or pass --stripe-secret-key <key>.",
);
if (flags.json) logger.json({ error: "missing_stripe_secret_key" });
process.exit(1);
}

const apiUrl = flags.apiUrl ?? config.api_url;
const webhookUrl = `${apiUrl}/webhooks/stripe/${config.program_id}`;

try {
const totalSteps = 3;
logger.newline();

const result = await runStripeOAuth({
clientId,
programId: config.program_id,
webhookUrl,
stripeSecretKey,
});

logger.progressStep(1, totalSteps, "Stripe authorized", true);

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

const saveResult = await api.saveStripeConnection({
program_id: config.program_id,
stripe_user_id: result.stripe_user_id,
webhook_endpoint_id: result.webhook_endpoint_id,
webhook_secret: result.webhook_secret,
});

logger.progressStep(2, totalSteps, "Connection saved", true);

// Update secrets with Stripe account ID
const currentSecrets = readSecrets();
if (currentSecrets) {
writeSecrets({
...currentSecrets,
stripe_account_id: result.stripe_user_id,
});
}

// Update config (for backward compat)
updateConfig({
stripe_connected: true,
stripe_account_id: result.stripe_user_id,
});

logger.progressStep(3, totalSteps, "Config updated", true);

if (flags.json) {
logger.json({
status: "connected",
stripe_account_id: result.stripe_user_id,
webhook_endpoint_id: result.webhook_endpoint_id,
charges_enabled: saveResult.charges_enabled,
payouts_enabled: saveResult.payouts_enabled,
});
return;
}

const chargesIcon = saveResult.charges_enabled ? format.green("✓") : format.yellow("⚠");
const payoutsIcon = saveResult.payouts_enabled ? format.green("✓") : format.yellow("⚠");

logger.titledBox("Stripe Connected", [
"",
` Account: ${format.bold(result.stripe_user_id)}`,
` Charges: ${chargesIcon} ${saveResult.charges_enabled ? "Enabled" : "Pending verification"}`,
` Payouts: ${payoutsIcon} ${saveResult.payouts_enabled ? "Enabled" : "Pending verification"}`,
"",
` ${format.bold("Webhooks auto-configured:")}`,
` ${format.green("✓")} customer.created ${format.dim("→ lead tracking")}`,
` ${format.green("✓")} checkout.session.completed ${format.dim("→ sale tracking")}`,
` ${format.green("✓")} invoice.paid ${format.dim("→ recurring commission")}`,
` ${format.green("✓")} charge.refunded ${format.dim("→ auto clawback")}`,
"",
]);

logger.info(` Test it: ${format.dim("$")} npx affitor test sale`);
logger.newline();
} catch (err) {
if (err instanceof StripeOAuthError) {
logger.error(err.message);
if (flags.json) logger.json({ error: "stripe_oauth_error", message: err.message });
} else if (err instanceof APIError) {
logger.error(`API error: ${err.message}`);
if (flags.json) logger.json({ error: "api_error", message: err.message });
} else if (err instanceof NetworkError) {
logger.error(err.message);
if (flags.json) logger.json({ error: "network_error" });
} else {
logger.error(`Unexpected error: ${(err as Error).message}`);
if (flags.json) logger.json({ error: "unexpected_error", message: (err as Error).message });
}
process.exit(1);
}
function runSetupStripe(flags: CLIFlags) {
logger.error(STRIPE_SETUP_MOVED);
if (flags.json) logger.json({ error: "stripe_setup_moved", message: STRIPE_SETUP_MOVED });
process.exitCode = 1;
}

function runSetupDns(flags: CLIFlags) {
Expand Down Expand Up @@ -227,6 +80,6 @@ function runSetupDns(flags: CLIFlags) {
"",
]);

logger.info(` Get started: ${format.dim("$")} npx affitor setup stripe`);
logger.info(` Get started: connect Stripe from the Affitor dashboard (program Settings, Connect Stripe).`);
logger.newline();
}
2 changes: 1 addition & 1 deletion packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export async function runStatus(flags: CLIFlags) {
logger.step(`DNS: Add CNAME t.${config.domain} → track.affitor.com`);
}
if (!status.stripe_connected) {
logger.step("Stripe: Run npx affitor setup stripe");
logger.step("Stripe: Connect it in the Affitor dashboard (program Settings, Connect Stripe)");
}
} catch (err) {
if (err instanceof APIError) {
Expand Down
13 changes: 0 additions & 13 deletions packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { DEFAULT_API_URL } from "../types.js";
import type {
InitResponse,
StripeConnectResponse,
ProgramStatus,
ProgramSummary,
TestEventResult,
Expand Down Expand Up @@ -115,18 +114,6 @@ export class AffitorAPI {
});
}

async saveStripeConnection(data: {
program_id: string;
stripe_user_id: string;
webhook_endpoint_id: string;
webhook_secret: string;
}): Promise<StripeConnectResponse> {
return this.request<StripeConnectResponse>("/api/v1/cli/stripe-connect", {
method: "POST",
body: data,
});
}

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