Files
5chan/scripts/update-blotter.js
T
plebeius 0cb7b1a970 feat(blotter): replace board stats with changelog-backed blotter
Add static blotter snapshot, route /blotter, compact board blotter (Hide/Show All).
Include yarn blotter tooling and AGENTS rules so every changelog version has a
release line. 4chan-style table UI, subplebbit→board and plebchan→5chan in messages.
2026-02-22 19:37:49 +08:00

309 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
/*
Blotter maintenance CLI
Path: src/data/5chan-blotter.json
Shape: { "entries": BlotterEntry[] }
BlotterEntry: id, kind (release|manual), timestamp (unix seconds), message;
version required when kind=release.
Release message format (strict): v{version}: {one-liner}
Modes:
- check Validate blotter: required fields, timestamp-desc order,
changelog coverage, release format.
- release Upsert release entry. Version from package.json (or --version).
Date from CHANGELOG.md heading. One-liner from --message.
- manual Add manual entry. Message required, timestamp defaults to now.
- interactive Interactive prompt for devs (yarn blotter).
Usage:
node scripts/update-blotter.js check
node scripts/update-blotter.js release --message "One-liner description" [--version 0.6.6]
node scripts/update-blotter.js manual --message "Manual dev message" [--timestamp 1739721600]
node scripts/update-blotter.js [interactive]
Release coverage ignores manual entries.
*/
import fs from 'fs/promises';
import { readFileSync } from 'fs';
import path from 'path';
import readline from 'readline';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const BLOTTER_PATH = path.join(ROOT, 'src', 'data', '5chan-blotter.json');
const CHANGELOG_PATH = path.join(ROOT, 'CHANGELOG.md');
const PACKAGE_JSON_PATH = path.join(ROOT, 'package.json');
const RELEASE_PREFIX_RE = /^v[\d.]+: .+$/;
function parseArgs(argv) {
const out = { flags: new Set(), positional: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = i + 1 < argv.length ? argv[i + 1] : undefined;
if (arg.startsWith('--')) {
const key = arg;
let value;
if (next && !next.startsWith('--')) {
value = next;
i++;
}
out[key] = value ?? '';
} else {
out.positional.push(arg);
}
}
return out;
}
function usage(exitCode = 1, msg) {
if (msg) console.error(msg);
console.error(`
Blotter maintenance CLI
yarn blotter Interactive mode
yarn blotter:check Validate (non-mutating)
yarn blotter:release --message "One-liner" [--version X.Y.Z]
yarn blotter:manual --message "Message" [--timestamp unix]
Path: src/data/5chan-blotter.json
Release format: v{version}: {one-liner}
`);
process.exit(exitCode);
}
async function loadBlotter() {
try {
const text = await fs.readFile(BLOTTER_PATH, 'utf8');
const data = JSON.parse(text);
let entries;
if (data && typeof data.entries === 'object' && Array.isArray(data.entries)) {
entries = data.entries;
} else if (Array.isArray(data)) {
entries = data;
} else {
throw new Error('Blotter must be { entries: BlotterEntry[] }');
}
return entries;
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
async function writeBlotter(entries) {
const sorted = [...entries].sort((a, b) => b.timestamp - a.timestamp);
const payload = { entries: sorted };
const json = JSON.stringify(payload, null, 2) + '\n';
await fs.mkdir(path.dirname(BLOTTER_PATH), { recursive: true });
await fs.writeFile(BLOTTER_PATH, json, 'utf8');
}
function getPackageVersion() {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
return pkg.version;
}
async function getChangelogVersions() {
const text = await fs.readFile(CHANGELOG_PATH, 'utf8');
const re = /#+ \[([^\]]+)\].*?\((\d{4}-\d{2}-\d{2})\)/g;
const versions = [];
let m;
while ((m = re.exec(text))) {
versions.push({ version: m[1], date: m[2] });
}
return versions;
}
function getReleaseDateForVersion(versions, version) {
const found = versions.find((v) => v.version === version);
return found ? found.date : null;
}
function dateToUnix(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
return Math.floor(d.getTime() / 1000);
}
function runCheck(entries) {
const errors = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (typeof e.id !== 'string' || !e.id.trim()) {
errors.push(`Entry ${i}: missing or invalid id`);
}
if (e.kind !== 'release' && e.kind !== 'manual') {
errors.push(`Entry ${i}: kind must be "release" or "manual"`);
}
if (typeof e.timestamp !== 'number' || typeof e.message !== 'string') {
errors.push(`Entry ${i}: missing timestamp or message`);
}
if (e.kind === 'release') {
if (typeof e.version !== 'string' || !e.version.trim()) {
errors.push(`Entry ${i}: release requires version field`);
} else if (!RELEASE_PREFIX_RE.test(e.message)) {
errors.push(`Entry ${i}: release message must match "vX.Y.Z: one-liner"`);
}
}
if (i > 0 && entries[i - 1].timestamp < e.timestamp) {
errors.push(`Entry ${i}: must be ordered timestamp-desc`);
}
}
return errors;
}
async function runChangelogCoverageCheck(entries) {
const releaseEntries = entries.filter((e) => e.kind === 'release');
const releaseVersions = new Set();
for (const e of releaseEntries) {
const v = e.version || (e.message && e.message.match(/^v([\d.]+):/)?.[1]);
if (v) releaseVersions.add(v);
}
const changelogVersions = await getChangelogVersions();
const missing = changelogVersions.filter((v) => !releaseVersions.has(v.version)).map((v) => v.version);
return missing;
}
async function modeCheck() {
const entries = await loadBlotter();
const errors = runCheck(entries);
if (errors.length) {
errors.forEach((e) => console.error(`Error: ${e}`));
process.exit(1);
}
const missingVersions = await runChangelogCoverageCheck(entries);
if (missingVersions.length) {
console.error(`Changelog versions missing release entries: ${missingVersions.join(', ')}`);
process.exit(1);
}
console.log('Blotter check passed.');
}
function formatReleaseMessage(version, oneLiner) {
const trimmed = (oneLiner || '').trim();
if (!trimmed) throw new Error('Release message (one-liner) is required');
return `v${version}: ${trimmed}`;
}
async function modeRelease(args) {
const message = args['--message'];
const versionArg = args['--version'];
const version = versionArg || getPackageVersion();
const changelogVersions = await getChangelogVersions();
const releaseDate = getReleaseDateForVersion(changelogVersions, version);
if (!releaseDate) {
console.error(`Version ${version} not found in CHANGELOG.md headings.`);
process.exit(1);
}
const timestamp = dateToUnix(releaseDate);
const fullMessage = formatReleaseMessage(version, message);
if (!message) {
console.error('--message is required for release mode');
process.exit(1);
}
const entries = await loadBlotter();
const id = `release-${version}`;
const existingIdx = entries.findIndex((e) => e.kind === 'release' && (e.version === version || e.id === id));
const newEntry = { id, kind: 'release', timestamp, message: fullMessage, version };
let updated;
if (existingIdx >= 0) {
updated = [...entries];
updated[existingIdx] = newEntry;
} else {
updated = [newEntry, ...entries];
}
await writeBlotter(updated);
console.log(existingIdx >= 0 ? `Updated release entry for v${version}` : `Added release entry for v${version}`);
}
async function modeManual(args) {
const message = args['--message'];
const tsArg = args['--timestamp'];
if (!message || !message.trim()) {
console.error('--message is required for manual mode');
process.exit(1);
}
const timestamp = tsArg ? parseInt(tsArg, 10) : Math.floor(Date.now() / 1000);
if (Number.isNaN(timestamp)) {
console.error('--timestamp must be a valid Unix timestamp');
process.exit(1);
}
const entries = await loadBlotter();
const id = `manual-${timestamp}`;
const newEntry = { id, timestamp, message: message.trim(), kind: 'manual' };
const updated = [newEntry, ...entries];
await writeBlotter(updated);
console.log('Added manual entry.');
}
async function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer?.trim() ?? '');
});
});
}
async function modeInteractive() {
console.log('Blotter interactive mode');
console.log(' 1) Add manual entry');
console.log(' 2) Add/update release entry');
console.log(' 3) Run check only');
const choice = await prompt('Choice [1-3]: ');
if (choice === '1') {
const message = await prompt('Message: ');
if (!message) {
console.error('Message is required');
process.exit(1);
}
await modeManual({ '--message': message });
} else if (choice === '2') {
const version = getPackageVersion();
const oneLiner = await prompt(`One-liner for v${version} [auto-from package.json]: `);
if (!oneLiner) {
console.error('One-liner is required');
process.exit(1);
}
await modeRelease({ '--message': oneLiner, '--version': version });
} else if (choice === '3') {
await modeCheck();
} else {
console.error('Invalid choice');
process.exit(1);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const mode = args.positional[0] || 'interactive';
if (mode === 'check') {
await modeCheck();
} else if (mode === 'release') {
await modeRelease(args);
} else if (mode === 'manual') {
await modeManual(args);
} else if (mode === 'interactive' || !mode) {
await modeInteractive();
} else {
usage(1, `Unknown mode: ${mode}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});