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
2 changes: 1 addition & 1 deletion 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.4
- `affitor init` writes the program's partner link parameter to `ref_param` (`via` for programs created from now on, `aff` for older ones), from the server's answer. An older server without that field still gives `aff`. The setup notes say which parameter partner links use; the tracker reads both `?aff=` and `?via=`.

## 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.

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "affitor",
"version": "0.4.3",
"version": "0.4.4",
"description": "CLI-native affiliate tracking. Connect Stripe, track commissions, manage partners — all from your terminal.",
"type": "module",
"bin": {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export async function runInit(
name: "_aff",
duration_days: cookieDuration,
},
ref_param: "aff",
ref_param: refParamFrom(response),
api_url: apiUrl,
created_at: new Date().toISOString(),
};
Expand Down Expand Up @@ -307,3 +307,8 @@ function formatCommission(
}
return `${value} per sale`;
}

/** The program's partner-code parameter from the init response; aff when an older server omits it. */
export function refParamFrom(response: { link_param?: string }): string {
return response.link_param === "via" || response.link_param === "aff" ? response.link_param : "aff";
}
2 changes: 1 addition & 1 deletion packages/cli/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ This project uses Affitor for affiliate/partner tracking. Affitor tracks clicks,

### 1. Click Tracking

Add this script tag to every page in \`<head>\`. It auto-detects affiliate visits via \`?aff=\` parameter and stores a first-party cookie.
Add this script tag to every page in \`<head>\`. It auto-detects affiliate visits via \`?${config.ref_param}=\` (it also reads \`?aff=\` and \`?via=\`) and stores a first-party cookie.

\`\`\`html
<script src="${config.api_url}/js/affitor-tracker.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { loadAffitor } from '@affitor/sdk';

/**
* Affitor affiliate tracking — auto-generated by \`affitor init\`.
* Captures ?aff= attribution on every page. Safe to keep at the app root.
* Captures ?via= and ?aff= attribution on every page. Safe to keep at the app root.
*/
export function ${TRACKER_MARKER}() {
useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface InitResponse {
api_key: string;
program_slug: string;
domain: string;
/** The partner-code parameter the program prints (via | aff). Older servers omit it. */
link_param?: string;
}

export interface ProgramStatus {
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/tests/init-ref-param.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { refParamFrom, runInit } from "../src/commands/init";
import { AffitorAPI } from "../src/lib/api-client";
import type { CLIFlags } from "../src/types";

const flags: CLIFlags = { json: true, noInteractive: true, autoConfirm: true, quiet: true, verbose: false };
const opts = { name: "Demo", domain: "demo.example.test", commissionType: "percent", commissionRate: 20 };

describe("refParamFrom", () => {
test("via and aff pass through; a missing or unknown value is aff", () => {
expect(refParamFrom({ link_param: "via" })).toBe("via");
expect(refParamFrom({ link_param: "aff" })).toBe("aff");
expect(refParamFrom({})).toBe("aff");
expect(refParamFrom({ link_param: "ref" })).toBe("aff");
expect(refParamFrom({ link_param: "VIA" })).toBe("aff");
});
});

describe("affitor init writes ref_param from the server", () => {
let home: string;
let project: string;
let cwd: string;
let originalHome: string | undefined;

beforeEach(() => {
cwd = process.cwd();
originalHome = process.env.HOME;
home = mkdtempSync(join(tmpdir(), "affitor-home-"));
mkdirSync(join(home, ".affitor"));
writeFileSync(
join(home, ".affitor", "credentials.json"),
JSON.stringify({ token: "jwt_test", email: "brand@example.test", expires_at: "2099-01-01T00:00:00.000Z" }),
);
process.env.HOME = home;
project = mkdtempSync(join(tmpdir(), "affitor-init-"));
process.chdir(project);
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
process.chdir(cwd);
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
rmSync(project, { recursive: true, force: true });
vi.restoreAllMocks();
});

const config = () => JSON.parse(readFileSync(join(project, ".affitor", "config.json"), "utf8"));

it.each([
[{ link_param: "via" }, "via"],
[{ link_param: "aff" }, "aff"],
[{}, "aff"],
[{ link_param: "ref" }, "aff"],
[{ link_param: 7 }, "aff"],
])("init response %j → ref_param %s in .affitor/config.json", async (extra, expected) => {
const init = vi
.spyOn(AffitorAPI.prototype, "initProgram")
.mockResolvedValue({ program_id: "58", api_key: "aff_test_key", ...extra } as never);

await runInit(opts, flags);

expect(init).toHaveBeenCalledTimes(1);
expect(config().ref_param).toBe(expected);
expect(config().program_id).toBe("58");
});

it("an existing config is left as it is: no API call, no write", async () => {
mkdirSync(join(project, ".affitor"));
const before = JSON.stringify({ version: 2, program_id: "12", ref_param: "aff" });
writeFileSync(join(project, ".affitor", "config.json"), before);
const init = vi.spyOn(AffitorAPI.prototype, "initProgram");

await expect(runInit(opts, flags)).rejects.toThrow("exit:1");

expect(init).not.toHaveBeenCalled();
expect(readFileSync(join(project, ".affitor", "config.json"), "utf8")).toBe(before);
expect(existsSync(join(project, ".affitor", ".env"))).toBe(false);
});
});
Loading