import { createClient } from "@libsql/client/web"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { ensureAnalyticsSchema, getWeeklyReport, renderWeeklyMarkdown, type Connections } from "~/server/weekly-metrics"; /** * Write `marketing/metrics/weekly.md` from the live analytics and licence DBs. * * bun --env-file=.env scripts/weekly-metrics.ts * bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z * * Required environment (all four; the analytics token also opens the licence * DB, which is why the weekly job can run from a checkout that has no Stripe * or Apple secrets): * * TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics * NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations * * This script deliberately does not import `~/env/server`: that schema demands * every production secret, and the metrics job needs two databases and nothing * else. * * The output path is anchored at the repo root, so running it from any cwd * writes the same file. */ interface Options { days: number; now: Date; out: string; stdout: boolean; } function usage(): string { return [ "Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]", "", " --days N window length in days (default 7)", " --now ISO end of the window (default: now)", " --out PATH where to write (default: marketing/metrics/weekly.md)", " --stdout print the report instead of writing a file" ].join("\n"); } function parseArgs(argv: string[]): Options { const repoRoot = resolve(import.meta.dir, ".."); const options: Options = { days: 7, now: new Date(), out: resolve(repoRoot, "marketing/metrics/weekly.md"), stdout: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]!; const value = () => { const next = argv[i + 1]; if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`); i += 1; return next; }; switch (arg) { case "--days": { const days = Number(value()); if (!Number.isFinite(days) || days <= 0) { throw new Error(`--days must be a positive number\n\n${usage()}`); } options.days = days; break; } case "--now": { const now = new Date(value()); if (Number.isNaN(now.getTime())) { throw new Error(`--now must be an ISO timestamp\n\n${usage()}`); } options.now = now; break; } case "--out": options.out = resolve(value()); break; case "--stdout": options.stdout = true; break; case "-h": case "--help": console.log(usage()); process.exit(0); default: throw new Error(`Unknown argument ${arg}\n\n${usage()}`); } } return options; } function requiredEnv(name: string): string { const value = process.env[name]; if (!value || value.trim() === "") { throw new Error( `${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` + `NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` + `\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.` ); } return value; } function connect(url: string, token: string) { return createClient({ url, authToken: token }); } async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const connections: Connections = { analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")), nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN")) }; // The app migrates the table on boot, but this job must not depend on a // deploy having happened: add any missing post-hoc columns (stage, host) // through the same idempotent bootstrap path first. await ensureAnalyticsSchema(connections.analytics); const report = await getWeeklyReport(connections, { days: options.days, now: options.now }); const markdown = renderWeeklyMarkdown(report); if (options.stdout) { process.stdout.write(markdown); return; } await mkdir(dirname(options.out), { recursive: true }); await writeFile(options.out, markdown, "utf8"); const f = report.counts; console.log( `wrote ${options.out}\n` + `window ${report.window.start} → ${report.window.end}\n` + `visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` + `trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` + `mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}` ); } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });