From 2d0ca85f3f412eadf857d3cf435c2fd5715c4c03 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Sun, 18 Oct 2026 23:36:27 -0700 Subject: [PATCH] fix: default new CLI programs to 60-day cookies --- packages/cli/README.md | 2 + packages/cli/src/commands/init.ts | 2 +- packages/cli/src/lib/prompts.ts | 2 +- .../init-cookie-duration-default.test.ts | 127 ++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tests/init-cookie-duration-default.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 29dcfad..51692b5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -219,6 +219,8 @@ npx affitor init \ --json ``` +New programs use a 60-day cookie window when `--cookie-duration` is omitted or the interactive default is accepted. An explicit value such as `--cookie-duration 90` is retained. `init` does not modify an already configured project. + ## What Gets Created Running `npx affitor init` creates: diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ab7eb5d..96d562d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -134,7 +134,7 @@ export async function runInit( .replace(/^https?:\/\//, "") .replace(/\/$/, ""); - cookieDuration ??= 90; + cookieDuration ??= 60; if (interactive && !flags.autoConfirm) { logger.newline(); diff --git a/packages/cli/src/lib/prompts.ts b/packages/cli/src/lib/prompts.ts index 3844513..f599848 100644 --- a/packages/cli/src/lib/prompts.ts +++ b/packages/cli/src/lib/prompts.ts @@ -64,7 +64,7 @@ export async function promptDurationMonths(): Promise { export async function promptCookieDuration(): Promise { return input({ message: "Cookie duration (days):", - default: "90", + default: "60", validate: (v) => { const n = parseInt(v, 10); if (isNaN(n) || n < 1) return "Enter a positive number"; diff --git a/packages/cli/tests/init-cookie-duration-default.test.ts b/packages/cli/tests/init-cookie-duration-default.test.ts new file mode 100644 index 0000000..f0041b5 --- /dev/null +++ b/packages/cli/tests/init-cookie-duration-default.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { registerInitCommand, runInit } from "../src/commands/init"; +import { setLoggerOptions } from "../src/lib/logger"; +import type { CLIFlags } from "../src/types"; + +const fixture = vi.hoisted(() => ({ + home: "", + input: vi.fn(async (options: { default?: string }) => options.default ?? ""), +})); +vi.mock("node:os", async (original) => ({ + ...(await original()), + homedir: () => fixture.home, +})); +vi.mock("@inquirer/prompts", () => ({ + input: fixture.input, + select: vi.fn(), + confirm: vi.fn(), +})); + +const options = { name: "Example", domain: "example.test", commissionType: "percent", commissionRate: 20 }; + +describe("new program cookie duration", () => { + let sandbox: string; + let project: string; + let previousCwd: string; + let server: Server; + let flags: CLIFlags; + let requests: { method?: string; path?: string; body: Record }[]; + + beforeEach(async () => { + previousCwd = process.cwd(); + sandbox = mkdtempSync(join(tmpdir(), "affitor-init-duration-")); + fixture.home = join(sandbox, "home"); + project = join(sandbox, "project"); + mkdirSync(join(fixture.home, ".affitor"), { recursive: true }); + mkdirSync(project); + writeFileSync(join(fixture.home, ".affitor", "credentials.json"), JSON.stringify({ + token: "fixture-session", email: "brand@example.test", expires_at: "2099-01-01T00:00:00.000Z", + })); + process.chdir(project); + fixture.input.mockReset().mockImplementation(async (question) => question.default ?? ""); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never); + setLoggerOptions({ quiet: true, json: true }); + requests = []; + server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ method: req.method, path: req.url, body: JSON.parse(body || "{}") }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ program_id: "42", api_key: "fixture-program-key", link_param: "aff" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + flags = { json: true, noInteractive: true, autoConfirm: true, quiet: true, verbose: false, + apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + process.chdir(previousCwd); + rmSync(sandbox, { recursive: true, force: true }); + setLoggerOptions({ quiet: false, json: false }); + vi.restoreAllMocks(); + }); + + function expectDuration(days: number) { + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ method: "POST", path: "/api/v1/cli/init", body: { cookie_duration: days } }); + const saved = JSON.parse(readFileSync(join(project, ".affitor", "config.json"), "utf8")); + expect(saved.cookie.duration_days).toBe(days); + expect(saved.program_id).toBe("42"); + } + + it("omitting the unattended option sends and saves 60 days", async () => { + await runInit(options, flags); + expectDuration(60); + expect(fixture.input).not.toHaveBeenCalled(); + }); + + it("accepting the real interactive prompt default sends and saves 60 days", async () => { + await runInit(options, { ...flags, noInteractive: false }); + expect(fixture.input).toHaveBeenCalledWith(expect.objectContaining({ message: "Cookie duration (days):", default: "60" })); + expectDuration(60); + }); + + it.each([30, 90, 365])("an explicit %i-day option is retained and bypasses the prompt", async (cookieDuration) => { + await runInit({ ...options, cookieDuration }, { ...flags, noInteractive: false }); + expectDuration(cookieDuration); + expect(fixture.input).not.toHaveBeenCalled(); + }); + + it("an interactive user can still choose 90 days", async () => { + fixture.input.mockResolvedValueOnce("90"); + await runInit(options, { ...flags, noInteractive: false }); + expectDuration(90); + }); + + it("the registered CLI parser retains --cookie-duration 90", async () => { + const command = new Command().option("--no-interactive").option("--json").option("--api-url "); + registerInitCommand(command); + await command.parseAsync(["node", "affitor", "--no-interactive", "--json", "--api-url", flags.apiUrl!, + "init", "--name", options.name, "--domain", options.domain, "--commission-type", options.commissionType, + "--commission-rate", String(options.commissionRate), "--cookie-duration", "90"]); + expectDuration(90); + }); + + it.each([undefined, 30])("an existing project's config, key and instructions remain byte-identical (option %s)", async (cookieDuration) => { + mkdirSync(join(project, ".affitor")); + const files = { + ".affitor/config.json": JSON.stringify({ version: 2, program_id: "existing", cookie: { duration_days: 90 } }), + ".affitor/.env": "AFFITOR_API_KEY=test_old\n", + "AGENTS.md": "Existing project instructions.\n", + }; + for (const [name, contents] of Object.entries(files)) writeFileSync(join(project, name), contents); + await expect(runInit({ ...options, cookieDuration }, flags)).rejects.toThrow("exit:1"); + expect(requests).toHaveLength(0); + expect(fixture.input).not.toHaveBeenCalled(); + for (const [name, contents] of Object.entries(files)) expect(readFileSync(join(project, name), "utf8")).toBe(contents); + }); +});