#!/usr/bin/env bun /** * hoist-readme-titles — for every workspace package, ensure README.md's * first line follows the convention: * * # @suaveplan/ - * * The `` is sourced from each package's `package.json` * `description` field. The script is idempotent: rerunning with no upstream * changes results in zero file writes. * * Usage: * bun scripts/hoist-readme-titles.ts # rewrite where needed * bun scripts/hoist-readme-titles.ts --check # exit 1 if any drift * bun scripts/hoist-readme-titles.ts --verbose # log every package * * Exit codes: * 0 — every package's README first line matches its package.json description. * 1 — drift detected (with `--check`) or write failures occurred. */ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { discoverPackageDirs } from "./lib/discover-packages.js"; const PKG_PREFIX = "@suaveplan/"; const TITLE_RE = /^#\s+`?@suaveplan\/([\w-]+)`?(?:\s+[-—]\s+(.*?))?\s*$/u; interface Report { updated: string[]; unchanged: string[]; missingDescription: string[]; missingReadme: string[]; malformedTitle: string[]; } function normalize(text: string): string { return text.replace(/\s+/g, " ").trim(); } function buildTitle(name: string, description: string): string { return `# @suaveplan/${name} - ${description}`; } interface ProcessResult { next: string; changed: boolean; malformed: boolean; } function rewriteFirstLine( contents: string, name: string, description: string ): ProcessResult { const newlineIndex = contents.indexOf("\n"); const firstLine = newlineIndex === -1 ? contents : contents.slice(0, newlineIndex); const rest = newlineIndex === -1 ? "" : contents.slice(newlineIndex); const match = firstLine.match(TITLE_RE); if (!match) { return { next: contents, changed: false, malformed: true }; } const parsedName = match[1]; if (parsedName !== name) { return { next: contents, changed: false, malformed: true }; } const existingDescription = (match[2] ?? "").trim(); const desired = normalize(description); if (existingDescription === desired) { return { next: contents, changed: false, malformed: false }; } const nextLine = buildTitle(name, desired); return { next: nextLine + rest, changed: true, malformed: false }; } interface CliOptions { check: boolean; verbose: boolean; } function parseArgs(argv: readonly string[]): CliOptions { let check = false; let verbose = false; for (const arg of argv) { if (arg === "--check") check = true; else if (arg === "--verbose" || arg === "-v") verbose = true; else throw new Error(`Unknown flag: ${arg}`); } return { check, verbose }; } async function main(): Promise { let options: CliOptions; try { options = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } const pkgDirs = await discoverPackageDirs(); const report: Report = { updated: [], unchanged: [], missingDescription: [], missingReadme: [], malformedTitle: [], }; for (const dir of pkgDirs) { const pkgJsonPath = join(dir, "package.json"); const readmePath = join(dir, "README.md"); let pkgJson: { name?: string; description?: string }; try { pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { name?: string; description?: string; }; } catch { continue; } if (!pkgJson.name?.startsWith(PKG_PREFIX)) continue; const shortName = pkgJson.name.slice(PKG_PREFIX.length); const description = (pkgJson.description ?? "").trim(); if (!description) { report.missingDescription.push(shortName); if (options.verbose) { console.log( ` missing description: @suaveplan/${shortName} (${dir})` ); } continue; } let readme: string; try { readme = readFileSync(readmePath, "utf-8"); } catch { report.missingReadme.push(shortName); if (options.verbose) { console.log(` missing README: @suaveplan/${shortName}`); } continue; } const result = rewriteFirstLine(readme, shortName, description); if (result.malformed) { report.malformedTitle.push(shortName); if (options.verbose) { console.log( ` malformed title: @suaveplan/${shortName} (expected "# @suaveplan/${shortName}" on line 1)` ); } continue; } if (result.changed) { if (!options.check) writeFileSync(readmePath, result.next); report.updated.push(shortName); if (options.verbose) { console.log(` updated: @suaveplan/${shortName}`); } } else { report.unchanged.push(shortName); } } console.log(""); console.log(`✏️ Updated: ${report.updated.length}`); console.log(`⏭️ Already current: ${report.unchanged.length}`); if (report.missingDescription.length > 0) { console.log( `⚠️ Missing description: ${report.missingDescription.length}` ); } if (report.missingReadme.length > 0) { console.log(`⚠️ Missing README: ${report.missingReadme.length}`); } if (report.malformedTitle.length > 0) { console.log(`❌ Malformed title: ${report.malformedTitle.length}`); for (const name of report.malformedTitle) { console.log(` - @suaveplan/${name}`); } } if (options.check) { if ( report.updated.length > 0 || report.malformedTitle.length > 0 || report.missingReadme.length > 0 ) { console.error(""); console.error( "Drift, malformed titles, or missing READMEs detected — run without --check to rewrite (malformed titles need manual fixing)." ); process.exit(1); } } // Default mode: diagnostic warnings only. Pipeline steps continue. process.exit(0); } void main();