diff --git a/bin/crawlproof.mjs b/bin/crawlproof.mjs new file mode 100755 index 00000000..b9f78aa6 --- /dev/null +++ b/bin/crawlproof.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// The `crawlproof` entry point. +// +// The CLI is TypeScript inside a Next.js app rather than a built package, so +// running it means running tsx against cli/index.ts. Everything here is about +// finding the repo and the right tsx no matter where the caller stood, because +// the alternative — telling people to cd into a checkout and type +// `npm run cli --` — is a setup step, and those do not survive contact. + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, ".."); +const entry = join(repo, "cli", "index.ts"); + +if (!existsSync(entry)) { + console.error(`crawlproof: cannot find ${entry}`); + console.error("The launcher must sit in bin/ inside the crawlproof.com checkout."); + process.exit(1); +} + +// Prefer the checkout's own tsx; fall back to npx so a fresh clone still runs. +const localTsx = join(repo, "node_modules", ".bin", "tsx"); +const [cmd, prefix] = existsSync(localTsx) ? [localTsx, []] : ["npx", ["--yes", "tsx"]]; + +const child = spawn(cmd, [...prefix, entry, ...process.argv.slice(2)], { + cwd: repo, + stdio: "inherit", + env: process.env, +}); + +child.on("error", (err) => { + console.error(`crawlproof: could not start tsx (${err.message})`); + console.error(`Run \`npm install\` in ${repo}.`); + process.exit(1); +}); + +// Relay the child's fate rather than inventing one: a TUI killed by ctrl+c +// should not look like a clean exit to whatever called this. +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/cli/index.ts b/cli/index.ts index 17c79e61..1f0afc18 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -185,9 +185,25 @@ async function cmdSweep(args: Args): Promise { // Both talk to /api/ads/v1/* with a CrawlProof API token (Social → API // tokens), the same token the MCP server and the myna plugin use. +/** + * The API token, from the flag, the environment, or ~/.crawlproof.json. + * + * The config file is last and exists so that using the CLI is not conditional + * on remembering to export a secret first. Same shape and same reasoning as + * ~/.coinpay.json, which `coinpayAuth` below reads. + */ function apiToken(args: Args): string | null { - const token = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN ?? null; - return token && token.trim() ? token.trim() : null; + const direct = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN; + if (direct && direct.trim()) return direct.trim(); + + try { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const file = process.env.CRAWLPROOF_CONFIG ?? `${home}/.crawlproof.json`; + const token = (JSON.parse(readFileSync(file, "utf8")) as { token?: string }).token; + return token && token.trim() ? token.trim() : null; + } catch { + return null; + } } function apiBase(args: Args): string { @@ -364,29 +380,8 @@ async function cmdStats(args: Args): Promise { return 0; } - const project = json.project as { name?: string; url?: string } | undefined; - const totals = json.totals as { visitors?: number; pageviews?: number } | undefined; - const list = (key: string) => (Array.isArray(json[key]) ? (json[key] as { label: string; value: number }[]) : []); - - process.stdout.write(`${project?.name ?? "project"} ${range} ${who}\n`); - process.stdout.write(`${totals?.visitors ?? 0} visitors, ${totals?.pageviews ?? 0} pageviews\n`); - - const section = (title: string, items: { label: string; value: number }[]) => { - if (!items.length) return; - process.stdout.write(`\n${title}\n`); - const width = Math.min(46, Math.max(...items.map((i) => i.label.length))); - for (const item of items.slice(0, 10)) { - process.stdout.write(` ${item.label.slice(0, width).padEnd(width)} ${item.value}\n`); - } - }; - section("Sources", list("sources")); - section("Referrers", list("referrers")); - section("Pages", list("pages")); - - // Nothing at all is a real answer, and the likeliest cause is worth naming. - if (!(totals?.pageviews ?? 0) && !list("sources").length) { - process.stdout.write(`\nNothing in this window. Check the tag is on the page, or widen --range.\n`); - } + const { renderStats } = await import("../lib/dashboard/stats-text"); + process.stdout.write(renderStats(json as Parameters[0], { range, who })); return 0; } @@ -593,7 +588,8 @@ ENV CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'. CRAWLPROOF_PROJECT Default project UUID for 'track'. CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'stats' and - 'dashboard'; --token overrides. + 'dashboard'; --token overrides. Falls back to the + 'token' field of ~/.crawlproof.json. COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money half of 'dashboard'. Defaults to jwtToken in ~/.coinpay.json, which 'coinpay auth login' writes. diff --git a/lib/dashboard/stats-text.ts b/lib/dashboard/stats-text.ts new file mode 100644 index 00000000..2fdbfbd2 --- /dev/null +++ b/lib/dashboard/stats-text.ts @@ -0,0 +1,47 @@ +// Rendering a /api/tracker/v1/stats answer as plain text. +// +// Pure and string-returning rather than writing to stdout, because two CLIs +// print it now — the in-repo one and the published @profullstack/crawlproof — +// and a printer that owns the process is a printer that cannot be shared or +// tested. + +export type StatsItem = { label: string; value: number }; + +export type StatsAnswerish = { + project?: { name?: string; url?: string } | null; + totals?: { visitors?: number; pageviews?: number } | null; + sources?: StatsItem[] | null; + referrers?: StatsItem[] | null; + pages?: StatsItem[] | null; +}; + +const list = (v: StatsItem[] | null | undefined): StatsItem[] => (Array.isArray(v) ? v : []); + +export function renderStats( + answer: StatsAnswerish, + { range, who, rows = 10 }: { range: string; who: string; rows?: number }, +): string { + const totals = answer.totals ?? {}; + const out: string[] = [ + `${answer.project?.name ?? "project"} ${range} ${who}`, + `${totals.visitors ?? 0} visitors, ${totals.pageviews ?? 0} pageviews`, + ]; + + const section = (title: string, items: StatsItem[]) => { + if (!items.length) return; + out.push("", title); + const width = Math.min(46, Math.max(...items.map((i) => i.label.length))); + for (const item of items.slice(0, rows)) { + out.push(` ${item.label.slice(0, width).padEnd(width)} ${item.value}`); + } + }; + section("Sources", list(answer.sources)); + section("Referrers", list(answer.referrers)); + section("Pages", list(answer.pages)); + + // Nothing at all is a real answer, and the likeliest cause is worth naming. + if (!(totals.pageviews ?? 0) && !list(answer.sources).length) { + out.push("", "Nothing in this window. Check the tag is on the page, or widen --range."); + } + return `${out.join("\n")}\n`; +} diff --git a/package.json b/package.json index 28b27648..80466287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "crawlproof", "version": "0.3.0", + "bin": { + "crawlproof": "./bin/crawlproof.mjs" + }, "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..4a5026d9 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,74 @@ +# @profullstack/crawlproof + +What the fleet costs and what it returns, in a terminal. + +``` +npm install -g @profullstack/crawlproof +crawlproof dashboard +``` + +Five live screens over three feeds that are not otherwise in the same place: +CrawlProof's tracker for who arrived, its ad network for what was delivered, +and CoinPay for what the bank actually did. + +| Screen | Answers | +| --- | --- | +| ROI | Monthly burn against revenue, cost per reader, break-even | +| Traffic | Every site on the account, ranked, with its share of the cost | +| Ads | Delivery as advertiser and as publisher, and ad-driven arrivals | +| Money | Earnings, bank position, invoices, income vs spending by month | +| Spend | Who we pay, largest first, and burn by category | + +`1`–`5` or Tab switches screens, `w` cycles the window, `b` cycles humans / +all / bots, `r` refreshes, `?` explains the arithmetic, `q` quits. + +## Two rules the numbers keep + +**Self-deal is not revenue.** Where an account advertises on its own slots, ad +spend and ad earnings are one dollar moving between two pockets. They are shown +under *Internal* and counted as neither cost nor revenue. + +**Personal money is not business cost.** A bank feed carries groceries next to +servers, so cost is the business scope only — joined from each transaction's +account to that account's scope. + +Everything is normalised to a monthly rate and then prorated onto the traffic +window, because burn is a rate: the traffic side can be asked for an hour while +the bank side only answers in weeks. + +The dashboard also reports what it cannot know. A site that did not answer is +missing rather than zero. A vendor list built from one page of a longer ledger +says so. A fleet whose visits run far above its pageviews — a "visitor" is any +non-crawler hit, which on a site with a machine-readable endpoint runs orders of +magnitude above pages anyone read — says that next to the number, and offers the +per-pageview figure instead. + +## Commands + +``` +crawlproof dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] + [--interval=60] [--sites=a.com,b.com] [--concurrency=8] + [--no-coinpay] [--json] +crawlproof stats [site] [--range=1d] [--who=humans] [--json] +``` + +`--json` prints the same snapshot the screens render, for a script or a box +with no terminal. Aliases for `dashboard`: `roi`, `tui`. + +## Auth + +| | | +| --- | --- | +| `CRAWLPROOF_TOKEN` | API token (`crp_…`) from Social → API tokens. Also read from the `token` field of `~/.crawlproof.json`. `--token` wins. | +| `CRAWLPROOF_SITE_URL` | API base, default `https://crawlproof.com`. | +| `COINPAY_SESSION_TOKEN` | CoinPay merchant JWT for the money screens. Defaults to `jwtToken` in `~/.coinpay.json`, which `coinpay auth login` writes. | + +Without a CoinPay session the traffic and ads screens still work and the money +panels say what is missing, rather than showing zero. + +Needs Node 22.6 or newer, and a terminal for the dashboard. `--json` needs +neither. + +## License + +MIT diff --git a/packages/cli/build.mjs b/packages/cli/build.mjs new file mode 100644 index 00000000..b3fe3586 --- /dev/null +++ b/packages/cli/build.mjs @@ -0,0 +1,36 @@ +// Bundle the publishable CLI out of the app's own source. +// +// The point of bundling rather than moving files is that lib/dashboard/* and +// cli/dashboard.ts stay where the test suite already covers them and where the +// in-repo CLI already imports them. The package is a build artifact of the +// same code, so there is one implementation and it cannot drift. +// +// hqtui and the CoinPay SDK stay external: they are real dependencies with +// their own release cadence, declared in package.json and installed alongside. + +import { build } from "esbuild"; +import { chmod, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = join(here, "dist", "cli.mjs"); + +const pkg = JSON.parse(await readFile(join(here, "package.json"), "utf8")); + +await build({ + entryPoints: [join(here, "src", "bin.ts")], + outfile: out, + bundle: true, + platform: "node", + format: "esm", + target: "node22", + // Anything with its own version belongs in package.json, not inlined here. + external: [...Object.keys(pkg.dependencies ?? {}), "node:*"], + banner: { js: "#!/usr/bin/env node" }, + legalComments: "none", + logLevel: "info", +}); + +await chmod(out, 0o755); +console.log(`built ${out}`); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..6c335574 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@profullstack/crawlproof", + "version": "0.1.0", + "description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.", + "license": "MIT", + "type": "module", + "homepage": "https://crawlproof.com", + "bin": { + "crawlproof": "./dist/cli.mjs" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=22.6" + }, + "scripts": { + "build": "node build.mjs", + "prepublishOnly": "node build.mjs" + }, + "dependencies": { + "@profullstack/coinpay": "^0.9.0", + "@profullstack/hqtui": "^0.1.11" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "crawlproof", + "tui", + "dashboard", + "analytics", + "roi" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/crawlproof.com.git", + "directory": "packages/cli" + } +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100644 index 00000000..a0333553 --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,7 @@ +// The executable entry. No shebang here: build.mjs adds one as a banner, and +// two of them is a syntax error rather than a harmless duplicate. +import { main } from "./cli"; + +main(process.argv.slice(2)).then((code) => { + process.exitCode = code ?? 0; +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 00000000..44cfeb44 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,228 @@ +// @profullstack/crawlproof — the read side of CrawlProof, on any box. +// +// Two commands, both token-authed and both pure HTTP, which is exactly why +// they can be published while the rest of the CLI cannot: `audit` needs the +// audit engines and their model SDKs, and `sweep` needs a cron secret. Those +// stay in the repo. What a box wants is to look, and that is this. +// +// The modules underneath are the same files the in-repo CLI runs and the same +// ones the test suite covers; this file is bundled from them rather than being +// a second copy of them. + +import { readFileSync } from "node:fs"; + +import { collectDashboard } from "../../../lib/dashboard/collect"; +import { renderStats } from "../../../lib/dashboard/stats-text"; +import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard"; + +export const VERSION = "0.1.0"; + +type Args = { + command: string; + positional: string[]; + flags: Record; +}; + +export function parseArgs(argv: string[]): Args { + const [command = "help", ...rest] = argv; + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < rest.length; i++) { + const a = rest[i] as string; + if (a.startsWith("--")) { + const eq = a.indexOf("="); + if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1); + else if (rest[i + 1] && !(rest[i + 1] as string).startsWith("--")) flags[a.slice(2)] = rest[++i] as string; + else flags[a.slice(2)] = true; + } else { + positional.push(a); + } + } + return { command, positional, flags }; +} + +/** Read one field out of a JSON config, or nothing at all if it is not there. */ +function fromConfig(file: string, field: string): string | null { + try { + const value = (JSON.parse(readFileSync(file, "utf8")) as Record)[field]; + return typeof value === "string" && value.trim() ? value.trim() : null; + } catch { + return null; + } +} + +const home = () => process.env.HOME ?? process.env.USERPROFILE ?? ""; + +export function apiToken(args: Args): string | null { + const direct = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN; + if (direct && direct.trim()) return direct.trim(); + return fromConfig(process.env.CRAWLPROOF_CONFIG ?? `${home()}/.crawlproof.json`, "token"); +} + +export function apiBase(args: Args): string { + const base = + (args.flags.base as string | undefined) ?? process.env.CRAWLPROOF_SITE_URL ?? "https://crawlproof.com"; + return base.replace(/\/$/, ""); +} + +/** + * The CoinPay merchant session, if this box has one. + * + * COINPAY_API_URL is the site origin in CrawlProof's environment but the + * CoinPay SDK's base must include /api. Accept either: the failure otherwise + * is an HTML page parsed as JSON, which names neither cause. + */ +export function coinpayAuth(args: Args): { token: string; baseUrl: string } | null { + const configured = ( + (args.flags["coinpay-url"] as string | undefined) ?? + process.env.COINPAY_API_URL ?? + "https://coinpayportal.com/api" + ).replace(/\/$/, ""); + const baseUrl = /\/api$/.test(configured) ? configured : `${configured}/api`; + + const token = + process.env.COINPAY_SESSION_TOKEN?.trim() || + fromConfig(process.env.COINPAY_CONFIG ?? `${home()}/.coinpay.json`, "jwtToken"); + return token ? { token, baseUrl } : null; +} + +const USAGE = `crawlproof — what the fleet costs and what it returns + +USAGE + crawlproof [options] + +COMMANDS + dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--interval=60] + [--sites=a.com,b.com] [--concurrency=8] [--no-coinpay] [--json] + A live dashboard of traffic across every site on the account, ad + delivery, and — when a CoinPay merchant session is on the box — the bank + feed behind it. Five screens: ROI, Traffic, Ads, Money, Spend. + Aliases: roi, tui. --json prints the same snapshot for a script. + + stats [site] [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--json] + Who arrived and from where: sources, referrers and top pages. Defaults + to the last day and humans only, because a launch is invisible inside a + month of crawler traffic. With one project the site can be left out. + + help | version + +AUTH + CRAWLPROOF_TOKEN API token (crp_…) from Social → API tokens, or the + "token" field of ~/.crawlproof.json. --token wins. + CRAWLPROOF_SITE_URL Override the API base (default https://crawlproof.com). + COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money screens. Defaults + to jwtToken in ~/.coinpay.json, which + 'coinpay auth login' writes. Without it the traffic + and ads screens still work. + +The dashboard needs Node 22.6+ and a terminal; --json needs neither. +`; + +async function cmdStats(args: Args): Promise { + const token = apiToken(args); + if (!token) { + console.error("Set CRAWLPROOF_TOKEN, or put a token in ~/.crawlproof.json."); + return 2; + } + const site = args.positional[0] ?? (args.flags.site as string | undefined) ?? process.env.CRAWLPROOF_PROJECT; + const range = (args.flags.range as string | undefined) ?? "1d"; + const who = (args.flags.who as string | undefined) ?? "humans"; + + const query = new URLSearchParams({ range, who }); + if (site) query.set("site", site); + + const res = await fetch(`${apiBase(args)}/api/tracker/v1/stats?${query.toString()}`, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + const json = (await res.json().catch(() => ({}))) as Record; + if (!res.ok) { + console.error(`error: ${String(json.error ?? res.status)}`); + return 1; + } + if (args.flags.json) { + console.log(JSON.stringify(json, null, 2)); + return 0; + } + process.stdout.write(renderStats(json as Parameters[0], { range, who })); + return 0; +} + +async function cmdDashboard(args: Args): Promise { + const token = apiToken(args); + if (!token) { + console.error("Set CRAWLPROOF_TOKEN, or put a token in ~/.crawlproof.json."); + return 2; + } + const range = (args.flags.range as string | undefined) ?? "1d"; + const who = (args.flags.who as string | undefined) ?? "humans"; + const only = + typeof args.flags.sites === "string" + ? args.flags.sites.split(",").map((s) => s.trim()).filter(Boolean) + : null; + const coinpay = args.flags["no-coinpay"] ? null : coinpayAuth(args); + const baseUrl = apiBase(args); + + if (args.flags.json) { + const snapshot = await collectDashboard({ + baseUrl, + token, + range, + who, + financeDays: FINANCE_DAYS[range] ?? 30, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + }); + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + return 0; + } + + if (!process.stdout.isTTY) { + console.error("The dashboard needs a terminal. Use --json for a snapshot, or `crawlproof stats`."); + return 2; + } + + await runDashboard({ + baseUrl, + token, + range, + who, + interval: Number(args.flags.interval) || 60, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + theme: args.flags.theme as string | undefined, + }); + return 0; +} + +export async function main(argv: string[]): Promise { + const args = parseArgs(argv); + try { + switch (args.command) { + case "dashboard": + case "roi": + case "tui": + return await cmdDashboard(args); + case "stats": + return await cmdStats(args); + case "version": + case "--version": + case "-v": + process.stdout.write(`${VERSION}\n`); + return 0; + case "help": + case "--help": + case "-h": + process.stdout.write(USAGE); + return 0; + default: + console.error(`unknown command: ${args.command}`); + process.stdout.write(USAGE); + return 2; + } + } catch (err) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} diff --git a/tests/dashboard-package-cli.test.ts b/tests/dashboard-package-cli.test.ts new file mode 100644 index 00000000..b15273f1 --- /dev/null +++ b/tests/dashboard-package-cli.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { apiBase, apiToken, coinpayAuth, parseArgs } from "@/packages/cli/src/cli"; +import { renderStats } from "@/lib/dashboard/stats-text"; + +const args = (argv: string[]) => parseArgs(argv); + +const saved = { ...process.env }; +afterEach(() => { + process.env = { ...saved }; +}); + +describe("parseArgs", () => { + it("reads --k=v, --k v and bare flags", () => { + const a = args(["dashboard", "site.com", "--range=1w", "--who", "bots", "--json"]); + expect(a.command).toBe("dashboard"); + expect(a.flags.range).toBe("1w"); + expect(a.flags.who).toBe("bots"); + expect(a.flags.json).toBe(true); + expect(a.positional).toEqual(["site.com"]); + }); + + // Known sharp edge, shared with the in-repo CLI: a bare flag takes the next + // bare word as its value, so a positional must not follow one. Pinned here + // so it stays a decision rather than becoming a surprise. + it("lets a bare flag swallow a following positional", () => { + const a = args(["stats", "--json", "site.com"]); + expect(a.flags.json).toBe("site.com"); + expect(a.positional).toEqual([]); + }); +}); + +describe("apiToken", () => { + it("prefers the flag, then the environment", () => { + process.env.CRAWLPROOF_TOKEN = "crp_env"; + expect(apiToken(args(["stats", "--token=crp_flag"]))).toBe("crp_flag"); + expect(apiToken(args(["stats"]))).toBe("crp_env"); + }); + + it("falls back to the config file so no export is needed", () => { + delete process.env.CRAWLPROOF_TOKEN; + const dir = mkdtempSync(join(tmpdir(), "cp-")); + const file = join(dir, "config.json"); + writeFileSync(file, JSON.stringify({ token: "crp_file" })); + process.env.CRAWLPROOF_CONFIG = file; + expect(apiToken(args(["stats"]))).toBe("crp_file"); + }); + + it("is null rather than empty when there is no token anywhere", () => { + delete process.env.CRAWLPROOF_TOKEN; + process.env.CRAWLPROOF_CONFIG = join(tmpdir(), "definitely-not-here.json"); + expect(apiToken(args(["stats"]))).toBeNull(); + }); +}); + +describe("apiBase", () => { + it("drops a trailing slash so paths do not double up", () => { + expect(apiBase(args(["stats", "--base=https://x.dev/"]))).toBe("https://x.dev"); + }); +}); + +describe("coinpayAuth", () => { + it("appends /api when the configured base is a bare origin", () => { + process.env.COINPAY_SESSION_TOKEN = "jwt"; + process.env.COINPAY_API_URL = "https://coinpayportal.com"; + expect(coinpayAuth(args(["dashboard"]))?.baseUrl).toBe("https://coinpayportal.com/api"); + }); + + it("leaves a base that already ends in /api alone", () => { + process.env.COINPAY_SESSION_TOKEN = "jwt"; + process.env.COINPAY_API_URL = "https://coinpayportal.com/api"; + expect(coinpayAuth(args(["dashboard"]))?.baseUrl).toBe("https://coinpayportal.com/api"); + }); + + it("is null with no session, so the money panels can say so", () => { + delete process.env.COINPAY_SESSION_TOKEN; + process.env.COINPAY_CONFIG = join(tmpdir(), "no-coinpay-here.json"); + expect(coinpayAuth(args(["dashboard"]))).toBeNull(); + }); +}); + +describe("renderStats", () => { + it("prints totals and each populated section", () => { + const text = renderStats( + { + project: { name: "site.com" }, + totals: { visitors: 10, pageviews: 4 }, + sources: [{ label: "Search · google", value: 9 }], + pages: [{ label: "/", value: 4 }], + }, + { range: "1d", who: "humans" }, + ); + expect(text).toContain("site.com 1d humans"); + expect(text).toContain("10 visitors, 4 pageviews"); + expect(text).toContain("Search · google"); + expect(text).not.toContain("Referrers"); + }); + + it("names the likely cause when there is nothing at all", () => { + const text = renderStats({ totals: { visitors: 0, pageviews: 0 } }, { range: "1d", who: "humans" }); + expect(text).toContain("Check the tag is on the page"); + }); +});