#!/usr/bin/env node /** * beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5. * * node scripts/beta.mjs export → data/exports/.csv, marks rows exported * node scripts/beta.mjs export --all → everything, including already-exported rows * node scripts/beta.mjs remove → a deletion request * node scripts/beta.mjs stats * * In the container, with the compose file of §6: * * docker compose exec site node scripts/beta.mjs export * * --------------------------------------------------------------------------------------- * WHY THIS IS A CLI AND NOT AN ADMIN PAGE * --------------------------------------------------------------------------------------- * §8 is explicit, and the argument is worth restating where somebody might be tempted to * "improve" it. An authenticated HTTP surface on a marketing site is a login form, a * session, a password to rotate, a lockout policy and a thing to patch — brought into * existence for an operation performed by the one person who already has shell on the host, * against a file already on their disk. Adding it would mean this site had an attack * surface where it currently has none, and the only thing gained is not having to type a * command. * * The CSV lands in the bind mount and is opened locally. Google Play has no API for adding * an individual tester — every route into a closed test ends with a human pasting a list — * so the last step is manual no matter how this is built. * * `remove` exists because §9 promises deletion on request, and a promise with no mechanism * behind it is a sentence. */ import fs from 'node:fs'; import path from 'node:path'; import { EXPORT_DIR, close, markExported, pending, removeSignup, stats, } from '../src/lib/betaStore.mjs'; const [command, ...rest] = process.argv.slice(2); const USAGE = ` node scripts/beta.mjs export [--all] write a CSV of the tester list node scripts/beta.mjs remove honour a deletion request node scripts/beta.mjs stats counts, and where the store lives `; /** * RFC 4180 quoting. Overkill for addresses that have already been validated against a * regex that admits no commas or quotes — and worth having anyway, because the day this * function is wrong is the day somebody pastes a corrupted list into a system that emails * strangers, and nothing about that failure would be visible in the CSV. */ const csvCell = (value) => { const text = value === null || value === undefined ? '' : String(value); return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; }; function doExport(all) { const rows = pending({ all }); if (!rows.length) { console.log( all ? 'Nothing to export — the list is empty.' : 'Nothing new to export. Use --all to re-export rows already marked exported.' ); return; } fs.mkdirSync(EXPORT_DIR, { recursive: true }); // Dated rather than sequential, and suffixed only if a second export happens the same // day: the file name should say when the list was taken, because that is the question // being asked when somebody finds three of these in a directory next year. const day = new Date().toISOString().slice(0, 10); let file = path.join(EXPORT_DIR, `${day}.csv`); for (let n = 2; fs.existsSync(file); n += 1) { file = path.join(EXPORT_DIR, `${day}-${n}.csv`); } // Two files, deliberately. Play's tester list wants addresses and nothing else — one per // line, ready to paste — while the CSV is the record: when they signed up, what they // agreed to, what state the row is in. Producing only the CSV would mean hand-editing it // before every paste, which is where a mistake would come from. const csv = [ ['id', 'email', 'created_at', 'status', 'consent_text'].join(','), ...rows.map((row) => [row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',') ), ].join('\r\n'); const listFile = file.replace(/\.csv$/, '.txt'); fs.writeFileSync(file, `${csv}\r\n`, 'utf8'); fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8'); const marked = markExported(rows.map((row) => row.id)); console.log(`Wrote ${rows.length} row(s):`); console.log(` ${file} the record`); console.log(` ${listFile} paste this into Play`); console.log(`Marked ${marked} row(s) exported.`); console.log( '\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' + 'Testers still have to open the opt-in link themselves; being on the list is not enough.' ); } function doRemove(email) { if (!email) { console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com'); process.exitCode = 2; return; } const result = removeSignup(email.trim().toLowerCase()); if (result.removed) { console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`); console.log( 'If that row was already exported, remove the address from the Play tester list too — ' + 'this store is not the only copy once a CSV has been pasted.' ); } else if (result.alreadyRemoved) { console.log(`#${result.id} was already removed. Nothing to do.`); } else { console.log('No such address on the list. Nothing to do.'); } } function doStats() { const s = stats(); const rows = [ ['store', s.path], ['total rows', s.total], ['new (not yet exported)', s.new], ['exported', s.exported], ['removed', s.removed], ['counting toward the cap', `${s.live} / ${s.cap}`], ['attempts, last 24h', s.attemptsLastDay], ]; const width = Math.max(...rows.map(([label]) => label.length)); for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`); } try { switch (command) { case 'export': doExport(rest.includes('--all')); break; case 'remove': doRemove(rest[0]); break; case 'stats': doStats(); break; default: console.log(USAGE); process.exitCode = command ? 2 : 0; } } finally { close(); }