mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
309 lines
9.6 KiB
JavaScript
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);
|
|
}
|
|
|
|
if (!message) {
|
|
console.error('--message is required for release mode');
|
|
process.exit(1);
|
|
}
|
|
|
|
const timestamp = dateToUnix(releaseDate);
|
|
const fullMessage = formatReleaseMessage(version, message);
|
|
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);
|
|
});
|